diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 66be80cd1231..f48bb0377ad9 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -38,8 +38,8 @@ This installs the dev client for the checkout's native fingerprint: from the device, the local cache, or the shared cache in seconds, building once only when no machine has that fingerprint yet (5-15 minutes, logged to the file it names). The device id may be omitted when exactly one device is booted. It -prints one JSON line; on failure it names the next command. Authorized mobile -verification includes that step unless the user prohibits it. +prints one JSON line; on failure it names the next command. Mobile verification +includes that step unless the user prohibits it. For a device you opened in an agent-device session elsewhere (a device pool, cloud, or remote host), run `vp run mobile:install --session ` diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a261..1c4ae817060a 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -59,6 +59,7 @@ github:myacoub91 github:naMqe-h github:nateEc github:naveed949 +github:nexxeln github:nmggithub github:Noojuno github:notkainoa diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml index 9396d8e3e7a6..41efb7b69a5a 100644 --- a/.github/workflows/agent-context-budget.yml +++ b/.github/workflows/agent-context-budget.yml @@ -51,5 +51,5 @@ jobs: echo "Accepted by the agent-context-increase label." exit 0 fi - echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text and rerun the evals that cover it (evals/README.md), or ask a maintainer to add the agent-context-increase label." exit 1 diff --git a/.github/workflows/desktop-macos-preview-publish.yml b/.github/workflows/desktop-macos-preview-publish.yml index bd6f59bc0f0b..6e8805b7f790 100644 --- a/.github/workflows/desktop-macos-preview-publish.yml +++ b/.github/workflows/desktop-macos-preview-publish.yml @@ -77,16 +77,19 @@ jobs: return; } - // workflow_run.pull_requests is empty for fork PRs, so resolve the - // PR from the built commit instead and require exactly one open PR - // from the same head repository and branch. The build baked its + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch + // and require exactly one at the built commit. The build baked its // PR number into the version, so two candidates would mean the // asset name could belong to either. - const associated = await github.paginate( - github.rest.repos.listPullRequestsAssociatedWithCommit, - { owner, repo, commit_sha: run.head_sha, per_page: 100 }, - ); - const matching = associated.filter( + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( (candidate) => candidate.state === "open" && candidate.head.sha === run.head_sha && diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 66ea4032a2a6..b0e663d5cb27 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -160,7 +160,20 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull production --non-interactive + run: | + eas env:pull production --non-interactive + # EAS commands skip dotenv, and the Android fingerprint hashes this + # file. Without it every merge misses the build's hash and rebuilds. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (!googleServicesFile) { + throw new Error("T3CODE_ANDROID_GOOGLE_SERVICES_FILE is missing from the production environment"); + } + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + NODE - name: Apply manual version override if: steps.version_app_token.outcome == 'success' diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 336f3c373964..5d8ae1528b5b 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -2,7 +2,8 @@ name: Mobile Fingerprint Check # Detects whether a PR changes the native fingerprint โ€” i.e. whether merging # it would leave main un-OTA-able until a new store build ships. Native-change -# PRs get the "๐Ÿ“ฑ Native Change" label so they can be held and merged as a +# PRs get the "๐Ÿ“ฑ Native Change" label (applied by mobile-fingerprint-label.yml, +# which runs from main so fork PRs get it too) so they can be held and merged as a # batch right before the next store submission, keeping main OTA-able for # everything else in between. (Once one native PR merges, every later merge # inherits the drifted fingerprint and loses OTA reach too โ€” that is why the @@ -27,8 +28,6 @@ jobs: runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }} permissions: contents: read - issues: write - pull-requests: write env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 @@ -127,94 +126,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT" - - name: Sync native change label - # Fork PRs get a read-only token under pull_request; the check stays - # advisory there (summary only). This workflow must not move to - # pull_request_target โ€” it installs and runs PR code. - if: github.event.pull_request.head.repo.full_name == github.repository - uses: actions/github-script@v8 + # mobile-fingerprint-label.yml reads this from main with a write token + # and syncs the label; this job's token is read-only for fork PRs. + - name: Record changed platforms env: CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "๐Ÿ“ฑ Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = context.payload.pull_request.number; - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } + run: | + mkdir -p "$RUNNER_TEMP/fingerprint-result" + printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms" - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); + - name: Upload changed platforms + uses: actions/upload-artifact@v7 + with: + name: fingerprint-result + path: ${{ runner.temp }}/fingerprint-result + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml new file mode 100644 index 000000000000..45e4c9f2ad42 --- /dev/null +++ b/.github/workflows/mobile-fingerprint-label.yml @@ -0,0 +1,189 @@ +name: Mobile Fingerprint Label + +# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code +# (fork PRs included) with a read-only token and records which platforms' +# fingerprints changed. This one runs from main with a write token and syncs +# the "๐Ÿ“ฑ Native Change" label. It never checks out or runs PR code. +# +# A PR can edit the check workflow it runs under, so the recorded result is +# untrusted data: it is validated, and at worst a PR mislabels itself. + +on: + workflow_run: + workflows: [Mobile Fingerprint Check] + types: [completed] + +permissions: + contents: read + +jobs: + label: + name: Sync native change label + # A cancelled run was superseded by a newer push, whose run labels instead. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + issues: write + pull-requests: write + steps: + - id: pr + name: Resolve the pull request behind the check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const run = context.payload.workflow_run; + const { owner, repo } = context.repo; + + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const results = artifacts.filter( + (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, + ); + if (results.length !== 1) { + core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); + return; + } + + // workflow_run.pull_requests and the commit-to-PR lookup are both + // empty for fork PRs, so list open PRs by head owner and branch. + // Requiring the PR's current head to be the checked commit keeps a + // slow run for an older push from overwriting a newer result. + const open = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + head: `${run.head_repository?.owner?.login}:${run.head_branch}`, + per_page: 100, + }); + const matching = open.filter( + (candidate) => + candidate.state === "open" && + candidate.head.sha === run.head_sha && + candidate.head.ref === run.head_branch && + candidate.head.repo?.full_name === run.head_repository?.full_name, + ); + if (matching.length !== 1) { + core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); + return; + } + + core.setOutput("artifact_id", String(results[0].id)); + core.setOutput("pr_number", String(matching[0].number)); + + # Read the archive as bytes; never extract PR-produced files into a + # workspace this job runs code from. + - id: result + name: Read changed platforms + if: steps.pr.outputs.pr_number != '' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} + run: | + set -euo pipefail + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" + changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" + if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then + echo "Unexpected changed-platforms value '$changed'." >&2 + exit 1 + fi + echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" + + - name: Sync native change label + if: steps.pr.outputs.pr_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} + with: + script: | + const managedLabel = { + name: "๐Ÿ“ฑ Native Change", + color: "d93f0b", + description: + "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", + }; + const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; + const issueNumber = Number(process.env.PR_NUMBER); + + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + }); + + if ( + existing.color !== managedLabel.color || + (existing.description ?? "") !== managedLabel.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: managedLabel.name, + color: managedLabel.color, + description: managedLabel.description, + }); + } catch (createError) { + if (createError.status !== 422) { + throw createError; + } + } + } + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + }); + const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); + + if (nativeChanged && !hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [managedLabel.name], + }); + } else if (!nativeChanged && hasLabel) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: managedLabel.name, + }); + } catch (removeError) { + if (removeError.status !== 404) { + throw removeError; + } + } + } + + core.info( + `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, + ); diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ba40c4fb89cb..5a16a9b4c170 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -390,6 +390,15 @@ jobs: mkdir -p "$target_dir" cp "native/resource-monitor/target/${{ inputs.rust_target }}/release/${binary_name}" "$target_dir/$binary_name" + - uses: ./.github/actions/setup-apt-mirrors + if: inputs.platform == 'linux' + + # The Linux archive ships libatomic.so.1 and points t3's RUNPATH at it. + - name: Install patchelf + if: inputs.cli_archive && inputs.platform == 'linux' + shell: bash + run: command -v patchelf || { sudo apt-get update && sudo apt-get install -y patchelf; } + - name: Build CLI archive if: inputs.cli_archive shell: bash @@ -433,9 +442,6 @@ jobs: path: release-cli/* if-no-files-found: error - - uses: ./.github/actions/setup-apt-mirrors - if: inputs.platform == 'linux' - - name: Install Linux desktop build libraries if: inputs.platform == 'linux' shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20daf8d15bd5..4b2bab1dc310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -762,11 +762,16 @@ jobs: timeout-minutes: 30 permissions: contents: write + # Release notes read PR bodies and authors. + pull-requests: read steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ needs.preflight.outputs.ref }} + # Release notes need history back to the previous tag, not file contents. + fetch-depth: 0 + filter: blob:none sparse-checkout: | /* !/.repos/ @@ -892,6 +897,27 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" + # A short model summary over GitHub's full list. Without a notes file, the + # release falls back to GitHub's generated notes. + - name: Write release notes + if: needs.preflight.outputs.release_channel != 'preview' && needs.preflight.outputs.previous_tag != '' + continue-on-error: true + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + node scripts/generate-release-notes.ts \ + --repository "${{ github.repository }}" \ + --tag "${{ needs.preflight.outputs.tag }}" \ + --ref "${{ needs.preflight.outputs.ref }}" \ + --previous-tag "${{ needs.preflight.outputs.previous_tag }}" \ + --version "${{ needs.preflight.outputs.version }}" \ + --output release-notes.md + if [[ -f release-notes.md ]]; then + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + fi + # A preview release gets a warning instead of generated notes. Generated # notes would list every commit since the previous preview, which is # unmerged branch history no one should read as a changelog, and would @@ -917,10 +943,11 @@ jobs: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} name: ${{ needs.preflight.outputs.release_name }} - generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} - body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} + generate_release_notes: ${{ hashFiles('release-notes.md') == '' }} + body_path: ${{ hashFiles('release-notes.md') != '' && 'release-notes.md' || '' }} previous_tag: ${{ needs.preflight.outputs.previous_tag }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} + draft: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true @@ -936,11 +963,23 @@ jobs: generate_release_notes: ${{ needs.preflight.outputs.release_channel != 'preview' }} body_path: ${{ needs.preflight.outputs.release_channel == 'preview' && 'release-notes.md' || '' }} prerelease: ${{ needs.preflight.outputs.is_prerelease }} + draft: ${{ needs.preflight.outputs.is_prerelease }} make_latest: ${{ needs.preflight.outputs.make_latest }} files: ${{ steps.release_files.outputs.files }} fail_on_unmatched_files: true token: ${{ github.token }} + # The action keeps a stable release a draft until its assets finish uploading, but publishes a + # prerelease first. Desktop clients update connected servers from the CLI archives as soon as + # they see the release, so a prerelease also goes public only once every asset is attached. + - name: Publish prerelease + if: needs.preflight.outputs.is_prerelease == 'true' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ needs.preflight.outputs.tag }} + run: gh release edit "$RELEASE_TAG" --draft=false + # The `t3` npm package is a launcher that downloads this version's CLI # archive from the GitHub Release, so it publishes only once that release # exists. Preview publishes too, under the `preview` dist-tag, which diff --git a/AGENTS.md b/AGENTS.md index e8eb6fd49b88..493f7fc0be71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real ## Verifying -- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, and tests beside changes. CI still runs the full test suites. +- Before you push, run `vp run check:changed`. It runs CI's fast checks for changed files: format, lint, knip, typecheck of affected packages and dependents, tests beside changes, and the cross-file checks those miss. CI still runs the full test suites. - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. @@ -135,11 +135,11 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real }); ``` -- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +- User-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. - Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. -For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. +For mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Evals @@ -209,5 +209,4 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ ## Additional tips -- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. - Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. diff --git a/README.md b/README.md index 36f558a79dc6..6f6dab985e15 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,16 @@ yay -S t3code-nightly-bin The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). +#### Nix (nixpkgs) + +```bash +nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code +``` + +The package is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code) +and includes the desktop app (`t3code-desktop`) and the `t3` command. +See [Nix installation](./docs/user/install.md#nix-nixpkgs) for NixOS configuration and updates. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 728ab36f7fcb..886ccf3cd798 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.3", + "version": "0.1.5", "private": true, "type": "module", "main": "dist-electron/boot.cjs", @@ -22,7 +22,7 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.9.3", + "electron": "44.10.0", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 3cdfdb6cfa38..8566613dd606 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, beforeEach, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -135,6 +136,14 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* command.uninstall; expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + + const launcher = path.join(home, ".t3", "bin", "t3"); + yield* fs.writeFileString(launcher, "npm's t3\n"); + process.env.PATH = path.dirname(launcher); + expect(yield* command.state).toMatchObject({ onPath: false, shadowedBy: launcher }); + expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); + yield* command.uninstall; + expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); }).pipe(Effect.scoped), ); @@ -183,6 +192,67 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "skips a t3 directory or a file this user cannot execute", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const folder = path.join(home, "folder"); + const plain = path.join(home, "plain"); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(path.join(folder, "t3"), { recursive: true }); + yield* fs.makeDirectory(plain); + yield* fs.writeFileString(path.join(plain, "t3"), "notes\n"); + // Root can execute mode 0o601; other users must honor the owner permissions. + yield* fs.chmod(path.join(plain, "t3"), process.getuid?.() === 0 ? 0o600 : 0o601); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = [folder, plain, shadow, path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + expect((yield* command.state).shadowedBy).toBe(path.join(shadow, "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain(path.join(shadow, "t3")); + + yield* fs.remove(shadow, { recursive: true }); + expect(yield* command.install).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + }).pipe(Effect.scoped), + ); + + it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcher = path.join(home, ".t3", "bin", "t3"); + process.env.PATH = [path.dirname(launcher), path.join(home, ".local", "bin")].join(":"); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + + expect(yield* fs.exists(launcher)).toBe(true); + expect(installed).toMatchObject({ + onPath: true, + installedPath: path.join(home, ".local", "bin", "t3"), + }); + expect(installed.shadowedBy).toBeUndefined(); + expect(yield* command.state).toEqual(installed); + + const removed = yield* command.uninstall; + expect(removed.installedPath).toBeNull(); + expect(removed.onPath).toBe(true); + expect(yield* command.state).toEqual(removed); + expect(yield* fs.exists(installed.installedPath!)).toBe(false); + expect(yield* fs.exists(launcher)).toBe(true); + }).pipe(Effect.scoped), + ); + it.effect("leaves the Windows PATH alone when it cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 2ae070d63826..f53895a60a57 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.access has no execute check. +import * as NodeFSP from "node:fs/promises"; + import type { DesktopCliCommandState } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -89,6 +92,19 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); + // PATH can reach the regular launcher directly; removal still requires isOurLink. + const runsOurLauncher = Effect.fn("DesktopCliCommand.runsOurLauncher")( + function* (candidate: string) { + if (yield* isOurLink(candidate)) return true; + const [resolved, ours] = yield* Effect.all([fs.realPath(candidate), fs.realPath(launcher)]); + if (resolved !== ours) return false; + const info = yield* fs.stat(candidate); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + return (yield* fs.readFileString(candidate)).includes(DesktopCliShim.MARKER); + }, + Effect.orElseSucceed(() => false), + ); + const powershell = (env: Record) => Effect.scoped( Effect.gen(function* () { @@ -118,10 +134,19 @@ export const make = Effect.gen(function* () { Effect.mapError(() => fail("Could not update your PATH.")), ); + const isExecutableFile = Effect.fn("DesktopCliCommand.isExecutableFile")( + function* (target: string) { + if ((yield* fs.stat(target)).type !== "File") return false; + yield* Effect.tryPromise(() => NodeFSP.access(target, NodeFSP.constants.X_OK)); + return true; + }, + Effect.orElseSucceed(() => false), + ); + const firstOnPath = Effect.gen(function* () { for (const directory of pathEntries(process.env.PATH, ":")) { const candidate = path.join(directory, "t3"); - if (yield* exists(candidate)) return Option.some(candidate); + if (yield* isExecutableFile(candidate)) return Option.some(candidate); } return Option.none(); }); @@ -129,7 +154,7 @@ export const make = Effect.gen(function* () { const foreignFirstOnPath = Effect.gen(function* () { if (windows) return Option.none(); const first = yield* firstOnPath; - if (Option.isNone(first) || (yield* isOurLink(first.value))) return Option.none(); + if (Option.isNone(first) || (yield* runsOurLauncher(first.value))) return Option.none(); return first; }); @@ -156,12 +181,17 @@ export const make = Effect.gen(function* () { const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; - if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; - } const first = yield* firstOnPath; - const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + const onPath = windows + ? Option.isSome(installed) + : Option.isSome(first) && (yield* runsOurLauncher(first.value)); + return { + supported: true, + installedPath: Option.getOrNull(installed), + onPath, + launcherPath, + ...shadow, + }; }).pipe( Effect.orElseSucceed(() => ({ supported: false, diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index feedbbd58007..9404b0ac3087 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,7 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -9,6 +13,8 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -56,6 +62,7 @@ const layerHandler = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly processSpawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly updateDesktopDatabaseExitCode?: number; readonly updateDesktopDatabaseStalled?: boolean; readonly updateDesktopDatabaseStarted?: Deferred.Deferred; @@ -106,6 +113,7 @@ const layerHandler = ( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => { + if (input.processSpawner) return input.processSpawner.spawn(command); const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; @@ -358,6 +366,69 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "continues registration after a timed-out cache helper ignores SIGTERM", + () => + Effect.gen(function* () { + const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const nodeExecutable = yield* HostProcess.ExecutablePath; + const recorded = emptyRecording(); + const spawned = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const processSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a single registration command"); + } + recorded.commands.push({ command: command.command, args: command.args }); + if (command.command !== "update-desktop-database") return mockProcess(0); + const child = yield* realSpawner.spawn( + ChildProcess.make( + nodeExecutable, + [ + "-e", + // The watchdog makes the unfixed case exit with a distinguishable code. + "process.on('SIGTERM', () => setTimeout(() => process.exit(99), 5000));" + + "process.stdout.write('ready\\n'); setInterval(() => {}, 2147483647);", + ], + { ...command.options, stdout: "pipe" }, + ), + ); + yield* Deferred.succeed(spawned, child); + yield* Stream.runHead(child.stdout); + yield* Deferred.succeed(ready, undefined); + return child; + }), + ); + const registration = yield* runRegister(recorded, { processSpawner }).pipe( + Effect.forkScoped, + ); + const child = yield* Deferred.await(spawned); + yield* Effect.addFinalizer(() => + child.isRunning.pipe( + Effect.flatMap((running) => + running ? child.kill({ killSignal: "SIGKILL" }) : Effect.void, + ), + Effect.ignore, + ), + ); + yield* Deferred.await(ready); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + const exit = yield* Effect.exit(child.exitCode); + if (Exit.isFailure(exit)) { + assert.include(Cause.pretty(exit.cause), "SIGKILL"); + } else { + assert.fail(`cache helper exited with ${exit.value} instead of being force-killed`); + } + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); interface ThemeIconRun { diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index dd38acbc3894..ebd7d71b2572 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -174,6 +174,7 @@ export const make = Effect.gen(function* () { stdin: "ignore", stdout: "ignore", stderr: "ignore", + forceKillAfter: "1 second", }, ); const handle = yield* spawner.spawn(command); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 45d1771f26f0..aec3a2d5b45c 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -130,6 +130,39 @@ describe("ElectronProtocol", () => { }).pipe(Effect.provide(layerProtocol)), ); + it.effect("keeps proxied paths on the configured server", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async () => new Response("ok")); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code-dev", + targetOrigin: new URL("http://127.0.0.1:5733/"), + clerkFrontendApiHostname: undefined, + }); + yield* Effect.promise(() => + handler!(new Request("t3code-dev://app//example.com/src/main.tsx?t=1")), + ); + yield* Effect.promise(() => handler!(new Request("t3code-dev://app/\\example.com/"))); + }), + ); + + assert.deepEqual( + netFetchMock.mock.calls.map((call) => call[0]), + [ + "http://127.0.0.1:5733//example.com/src/main.tsx?t=1", + "http://127.0.0.1:5733//example.com/", + ], + ); + }).pipe(Effect.provide(layerProtocol)), + ); + it.effect("rejects custom protocol requests for another host", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d84b6f360c89..27af3f2cfd8a 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -167,7 +167,10 @@ const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* ( return new Response(null, { status: 404 }); } - const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin); + // Assign the path so leading slashes cannot replace the configured host. + const targetUrl = new URL(targetOrigin); + targetUrl.pathname = requestUrl.pathname; + targetUrl.search = requestUrl.search; const headers = new Headers(request.headers); const headersToRemove: string[] = []; for (const name of headers.keys()) { diff --git a/apps/desktop/src/ipc/methods/updates.ts b/apps/desktop/src/ipc/methods/updates.ts index b2212609030d..ca85ee8b0c41 100644 --- a/apps/desktop/src/ipc/methods/updates.ts +++ b/apps/desktop/src/ipc/methods/updates.ts @@ -23,11 +23,14 @@ export const getUpdateState = DesktopIpc.makeIpcMethod({ export const setUpdateChannel = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, - payload: DesktopUpdateChannelSchema, + payload: Schema.Struct({ + channel: DesktopUpdateChannelSchema, + minimumVersion: Schema.optional(Schema.String), + }), result: DesktopUpdateStateSchema, - handler: Effect.fn("desktop.ipc.updates.setChannel")(function* (channel) { + handler: Effect.fn("desktop.ipc.updates.setChannel")(function* ({ channel, minimumVersion }) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.setChannel(channel); + return yield* updates.setChannel(channel, { minimumVersion }); }), }); @@ -43,11 +46,14 @@ export const downloadUpdate = DesktopIpc.makeIpcMethod({ export const installUpdate = DesktopIpc.makeIpcMethod({ channel: IpcChannels.UPDATE_INSTALL_CHANNEL, - payload: Schema.Void, + /** A version installs only that downloaded build, never one that replaced it. */ + payload: Schema.UndefinedOr(Schema.String), result: DesktopUpdateActionResultSchema, - handler: Effect.fn("desktop.ipc.updates.install")(function* () { + handler: Effect.fn("desktop.ipc.updates.install")(function* (expectedVersion) { const updates = yield* DesktopUpdates.DesktopUpdates; - return yield* updates.install; + if (expectedVersion === undefined) return yield* updates.install; + const { accepted, completed, state } = yield* updates.installPrepared(expectedVersion); + return { accepted, completed, state }; }), }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 66b1031712b0..59bcc539098c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -263,11 +263,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), - setUpdateChannel: (channel) => - ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), + setUpdateChannel: (channel, minimumVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, { channel, minimumVersion }), checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), - installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + installUpdate: (expectedVersion) => + ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL, expectedVersion), cliCommand: { getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 22af4a829e04..b1f2b7ca0df9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -280,11 +280,16 @@ describe("BrowserSession", () => { assert.isUndefined(sessions.get(partition)); yield* browserSessions.getSession("scope-untouched"); + const other = yield* browserSessions.getSession("scope-other"); yield* browserSessions.clearCookies([partition]); const created = sessions.get(partition); assert.isDefined(created); assert.strictEqual(created.clearStorageData.mock.calls.length, 1); + assert.deepStrictEqual(created.clearStorageData.mock.calls[0]?.[0], { + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], + }); + assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 2ea7efdb88ee..e467c370d0f5 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -367,7 +367,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { Effect.tryPromise({ try: () => browserSession.clearStorageData({ - storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], + storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"], }), catch: (cause) => new BrowserSessionStorageClearError({ diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35..a0624ced669e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -22,7 +22,7 @@ import { normalizeLinuxPasswordStorePreference, type LinuxPasswordStorePreference, } from "../linuxSecretStorage.ts"; -import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { @@ -180,7 +180,7 @@ export class DesktopAppSettings extends Context.Service< export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { return { ...DEFAULT_DESKTOP_SETTINGS, - updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), + updateChannel: desktopUpdateChannelOf(appVersion), }; } diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea351b6ceb2f..ec5062f9105b 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", boringSidebarEnabled: false, boringSidebarMultilineRows: false, + threadDetailsUsageEnabled: false, + threadDetailsItems: {}, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9d12ed5a07fe..480482306902 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -149,7 +149,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: ChildProcess.Command[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -167,6 +167,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + assert.isTrue(prepared); }), ); @@ -267,7 +268,7 @@ describe("DesktopShellEnvironment", () => { }; const commands: string[] = []; - yield* runShellEnvironment({ + const prepared = yield* runShellEnvironment({ env, platform: "darwin", handler: (command) => { @@ -279,6 +280,19 @@ describe("DesktopShellEnvironment", () => { assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.isFalse(prepared); + }), + ); + + it.effect("leaves macOS preparation incomplete when no login shell returns a PATH", () => + Effect.gen(function* () { + const prepared = yield* runShellEnvironment({ + env: { SHELL: "/bin/zsh", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }, + platform: "darwin", + handler: () => "", + }); + + assert.isFalse(prepared); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 25c37ecefc05..6e0fdbd38050 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -392,7 +392,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< - void, + boolean, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { @@ -491,6 +491,8 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } } + + return Option.isSome(trimNonEmpty(shellEnvironment.PATH)); }, ); @@ -505,7 +507,7 @@ const installShellEnvironment = ( return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { - return installPosixEnvironment(config).pipe(Effect.as(true)); + return installPosixEnvironment(config); } return Effect.succeed(false); }; diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 253a015d8ec9..1f5321148b77 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -119,6 +119,239 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("switches to the requested track before preparing its release", () => { + // In-memory settings keep the channel write off real disk I/O. + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-switch"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + assert.equal((yield* updates.getState).channel, "nightly"); + + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => [report.requestId, report.outcome]), + [["req-switch", "ready-to-install"]], + ); + assert.equal(terminals[0]?.state.downloadedVersion, "1.2.4-nightly.20261027.1"); + }), + ); + }); + + it.effect( + "refuses a crossing whose feed has only an older build, and never offers it later", + () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const older = "1.2.4-nightly.20261020.1"; + yield* Queue.offer(requests, { + ...request("req-older"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + // Answers the track change's check, then the request's own check. + harness.emit("update-available", { version: older }); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.4-nightly.20261027.1."]], + ); + // The saved track is the user's intent; a retry picks it up from here. + assert.equal((yield* updates.getState).channel, "nightly"); + + // A plain update afterwards cannot reach the build the switch refused. + yield* Queue.offer(requests, request("req-plain")); + yield* settle; + harness.emit("update-available", { version: older }); + yield* settle; + assert.isNull((yield* updates.getState).availableVersion); + assert.equal(harness.downloadCount(), 0); + }), + ); + }, + ); + + it.effect("only saves the track on a host already running the release", () => { + const harness = makeHarness({ savedChannel: "nightly" }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-track-only"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + // Even with a newer build on the feed, the host only saves its track. + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["up-to-date"], + ); + assert.equal((yield* updates.getState).channel, "latest"); + assert.equal(harness.downloadCount(), 0); + }), + ); + }); + + it.effect("drops a retained download below the switch's checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + // Downloaded earlier on this Stable host, before the switch checked 1.2.5. + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.equal((yield* updates.getState).downloadedVersion, "1.2.4"); + + yield* Queue.offer(requests, { + ...request("req-floor"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + harness.emit("update-available", { version: "1.2.5" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.5" }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", "1.2.5"]], + ); + }), + ); + }); + + it.effect("refuses to cross tracks remotely while WSL backends need their own checks", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void, wslBackend: true }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, { + ...request("req-wsl"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["failed"], + ); + assert.include(terminalReports(reports)[0]?.reason ?? "", "Switch it on that machine"); + assert.equal((yield* updates.getState).channel, "latest"); + }), + ); + }); + + it.effect("fails a switch that leaves a same-track host below the checked release", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-behind"), + channel: "latest", + targetVersion: "1.2.5", + }); + yield* settle; + // The feed lags npm and has nothing newer than this host's 1.2.3. + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.5."]], + ); + }), + ); + }); + + it.effect("fails a crossing to an older-numbered Stable that the feed does not offer", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-to-stable"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + // Still on Nightly, though the target is numbered below this build. + assert.deepEqual( + terminalReports(reports).map((report) => [report.outcome, report.reason]), + [["failed", "The desktop app found no build of 1.2.3."]], + ); + }), + ); + }); + + it.effect("accepts a newer build than the checked release on the requested track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const newer = "1.2.4-nightly.20261028.2"; + yield* Queue.offer(requests, { + ...request("req-newer"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + harness.emit("update-available", { version: newer }); + yield* settle; + harness.emit("update-downloaded", { version: newer }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => [ + report.outcome, + report.state.downloadedVersion, + ]), + [["ready-to-install", newer]], + ); + }), + ); + }); + it.effect("reports a failed outcome when quitAndInstall fails", () => { const harness = makeHarness({ quitAndInstall: Effect.fail( @@ -525,6 +758,42 @@ describe("DesktopRemoteUpdates", () => { ); }); + it.effect("cancels a track switch while its own feed check is still running", () => { + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.await(releaseCheck), + }); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, { + ...request("req-switch-cancel"), + channel: "nightly", + targetVersion: "1.2.4-nightly.20261027.1", + }); + yield* settle; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-switch-cancel", + }); + yield* settle; + // The next request starts without waiting for the cancelled switch's feed check. + yield* Queue.offer(requests, { + ...request("req-switch-back"), + channel: "latest", + targetVersion: "1.2.3", + }); + yield* settle; + + assert.equal((yield* updates.getState).channel, "latest"); + yield* Deferred.succeed(releaseCheck, undefined); + }), + ); + }); + it.effect("cancels an active preparation so the next request can run", () => { const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb606d8fbd25..560e2e78d4bb 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -10,10 +10,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { @@ -44,9 +47,13 @@ type CommitClaim = export const listen: Effect.Effect< void, never, - DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope + | DesktopUpdates.DesktopUpdates + | DesktopTelemetryPublisher.DesktopTelemetryPublisher + | DesktopBackendPool.DesktopBackendPool + | Scope.Scope > = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; + const backends = yield* DesktopBackendPool.DesktopBackendPool; const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; const activeRequestIdRef = yield* Ref.make(Option.none()); const requestControlRef = yield* Ref.make<{ @@ -171,7 +178,59 @@ export const listen: Effect.Effect< return; } yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); - yield* logInfo("remote update requested", { requestId: request.requestId }); + yield* logInfo("remote update requested", { + requestId: request.requestId, + channel: request.channel, + }); + // A switch saves its track first, so the step machine never sees the old track's offer. + // The saved track is the user's intent and stays even if this request fails: a track saved + // toward Stable never offers an older build outside a guarded switch. + if (request.channel !== undefined) { + // A reinstall restarts every backend, but the requesting server checked only its own + // database. A WSL backend keeps its own, so cross tracks only where all can be checked. + const current = yield* updates.getState; + if ( + desktopUpdateChannelOf(current.currentVersion) !== request.channel && + (yield* backends.list).length > 1 + ) { + yield* publishReport( + current, + { + outcome: "failed", + reason: + "This desktop app also runs WSL backends, whose databases a remote switch cannot check. Switch it on that machine.", + }, + request.requestId, + ); + return; + } + const switched = yield* Effect.raceFirst( + updates.setChannel(request.channel, { minimumVersion: request.targetVersion }).pipe( + Effect.as(true), + Effect.catch((error) => + updates.getState.pipe( + Effect.flatMap((state) => + publishReport( + state, + { outcome: "failed", reason: error.message }, + request.requestId, + ), + ), + Effect.as(false), + ), + ), + ), + Deferred.await(cancellation).pipe(Effect.as(false)), + ); + if (!switched) return; + // A host already running the target only needed its track saved; installing a newer + // build here would restart it without the dialog having said so. + const state = yield* updates.getState; + if (request.targetVersion === state.currentVersion) { + yield* publishReport(state, { outcome: "up-to-date" }, request.requestId); + return; + } + } const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; @@ -184,6 +243,12 @@ export const listen: Effect.Effect< Effect.forkScoped, ); + // A host leaving its build's track may need an older build, down to the checked release; + // the updater offers nothing below it. + const crossing = (state: DesktopUpdateState) => + request.channel !== undefined && + request.targetVersion !== undefined && + desktopUpdateChannelOf(state.currentVersion) !== request.channel; const step = Effect.fn("DesktopRemoteUpdates.step")(function* ( state: DesktopUpdateState, ): Effect.fn.Return { @@ -193,7 +258,7 @@ export const listen: Effect.Effect< return false; case "check": attempts = { ...attempts, checks: attempts.checks + 1 }; - yield* updates.check("remote-update").pipe( + yield* updates.check("remote-update", { allowDowngrade: crossing(state) }).pipe( Effect.flatMap((result) => { if (result.checked) return Effect.void; attempts = { ...attempts, checks: attempts.checks - 1 }; @@ -246,21 +311,34 @@ export const listen: Effect.Effect< yield* logInfo("remote update prepared", { requestId: request.requestId }); return true; } - case "done": + case "done": { + // A switch that leaves the host on its old track, or below its checked release, did + // not reach it; a track-only host already at the release is done. + const done = + request.targetVersion !== undefined && + next.outcome === "up-to-date" && + (crossing(state) || + compareSemverVersions(state.currentVersion, request.targetVersion) < 0) + ? { + outcome: "failed" as const, + reason: `The desktop app found no build of ${request.targetVersion}.`, + } + : next; yield* publishReport( state, { - outcome: next.outcome, - ...(next.reason === undefined ? {} : { reason: next.reason }), + outcome: done.outcome, + ...(done.reason === undefined ? {} : { reason: done.reason }), }, request.requestId, ); yield* logInfo("remote update finished", { requestId: request.requestId, - outcome: next.outcome, - reason: next.reason ?? null, + outcome: done.outcome, + reason: done.reason ?? null, }); return true; + } } }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index d2f24143fc20..c6bcd46b8c20 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -694,6 +695,65 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("ignores a finished download from a track it no longer follows", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + // A backed-out switch's download keeps reporting progress until it finishes. + harness.emit("download-progress", { percent: 40 }); + yield* flushCallbacks; + harness.emit("update-downloaded", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.downloadedVersion, null); + assert.notEqual(state.status, "downloading"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("offers older releases only to checks that leave the running build's track", () => { + const harness = makeHarness({ beforeSetUpdateChannel: Effect.void }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + const before = harness.checkAllowedDowngrade.length; + yield* updates.setChannel("nightly"); + yield* updates.setChannel("latest"); + yield* updates.check("remote-update", { allowDowngrade: true }); + + // Back on its own Stable track the build never needs an older release. + assert.deepEqual(harness.checkAllowedDowngrade.slice(before), [true, false, true]); + assert.isFalse(harness.allowDowngrade()); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("re-checks a saved track that differs from the running build's track", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest"); + const checks = harness.checkCount(); + + yield* updates.setChannel("latest"); + + assert.equal(harness.checkCount(), checks + 1); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("does not persist an unchanged update channel as a user preference", () => { const harness = makeHarness(); @@ -713,6 +773,116 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("offers nothing below a track switch's checked release", () => { + const harness = makeHarness({ + appVersion: "1.2.4-nightly.20261027.1", + beforeSetUpdateChannel: Effect.void, + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.setChannel("latest", { minimumVersion: "1.2.3" }); + + harness.emit("update-available", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + harness.emit("update-downloaded", { version: "1.2.2" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).downloadedVersion); + + harness.emit("update-available", { version: "1.2.3" }); + yield* flushCallbacks; + assert.equal((yield* updates.getState).availableVersion, "1.2.3"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Stable track across a restart without offering an older release", () => { + // A Nightly build whose switch to Stable did not finish before the app quit. + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1", savedChannel: "latest" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + + assert.equal((yield* updates.getState).channel, "latest"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + // Semver ranks Stable 1.2.4 above this Nightly, but a Nightly built before the release's + // version bump can hold migrations 1.2.4 lacks. Crossing needs a guarded switch. + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + assert.isNull((yield* updates.getState).availableVersion); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps a saved Nightly track across a restart without crossing on its own", () => { + // A Stable build whose switch to Nightly was refused before the app quit; its floor is gone. + const harness = makeHarness({ savedChannel: "nightly" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("startup"); + harness.emit("update-available", { version: "1.2.4-nightly.20261027.1" }); + yield* flushCallbacks; + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.isNull((yield* updates.getState).availableVersion); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("never offers an older Nightly to a Nightly build's own checks", () => { + const harness = makeHarness({ appVersion: "1.2.4-nightly.20261027.1" }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll"); + + assert.equal((yield* updates.getState).channel, "nightly"); + assert.deepEqual(harness.checkAllowedDowngrade, [false]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("waits out a background check instead of refusing a track change", () => + Effect.gen(function* () { + const checkStarted = yield* Deferred.make(); + const releaseCheck = yield* Deferred.make(); + const harness = makeHarness({ + beforeSetUpdateChannel: Effect.void, + checkForUpdates: Deferred.succeed(checkStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCheck)), + ), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* updates.check("poll").pipe(Effect.forkScoped); + yield* Deferred.await(checkStarted); + + const channelFiber = yield* updates.setChannel("nightly").pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseCheck, undefined); + yield* TestClock.adjust(Duration.seconds(2)); + const state = yield* Fiber.join(channelFiber); + + assert.equal(state.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("rejects checks while an update channel change is being persisted", () => Effect.gen(function* () { const channelChangeStarted = yield* Deferred.make(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aeda73f2bfad..3a0b41cd9e36 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -19,6 +19,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -34,7 +35,8 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -49,6 +51,8 @@ import { resolveNotAppliedInstallVersion, } from "./updateMachine.ts"; +const CHANNEL_CHANGE_RETRY_DELAY = Duration.seconds(1); +const CHANNEL_CHANGE_RETRIES = 30; const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); @@ -66,6 +70,7 @@ const UpdateInfo = Schema.Struct({ version: Schema.String, // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), + releaseDate: Schema.optional(Schema.String), }); const DownloadProgressInfo = Schema.Struct({ @@ -179,10 +184,16 @@ export class DesktopUpdates extends Context.Service< readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; + /** `minimumVersion` is a track switch's checked release; nothing older is offered after. */ readonly setChannel: ( channel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) => Effect.Effect; - readonly check: (reason: string) => Effect.Effect; + /** `allowDowngrade` is for a guarded track switch, which checked that older release. */ + readonly check: ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; readonly installPrepared: ( @@ -351,6 +362,20 @@ export const make = Effect.gen(function* () { const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const notAppliedVersionRef = yield* Ref.make(Option.none()); + // The release a track switch's database check cleared. A build crosses to another track only + // through such a switch, and never below that release: version order alone cannot tell whether + // a Stable knows a Nightly's migrations. + const releaseFloorRef = yield* Ref.make(Option.none()); + const offerable = (version: string, state: DesktopUpdateState) => + Ref.get(releaseFloorRef).pipe( + Effect.map( + (floor) => + desktopUpdateChannelOf(version) === state.channel && + (Option.isSome(floor) + ? compareSemverVersions(version, floor.value) >= 0 + : desktopUpdateChannelOf(environment.appVersion) === state.channel), + ), + ); const updateStateRef = yield* Ref.make( createInitialDesktopUpdateState( environment.appVersion, @@ -454,12 +479,13 @@ export const make = Effect.gen(function* () { const allowsPrerelease = channel === "nightly"; yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); - yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + // An older build can lack migrations this one ran. Only a guarded track switch's own check + // may offer one. + yield* electronUpdater.setAllowDowngrade(false); yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, - allowDowngrade: allowsPrerelease, fullChangelog: allowsPrerelease, }); }); @@ -467,6 +493,7 @@ export const make = Effect.gen(function* () { const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* ( reason: string, actionReservation: "acquire" | "held" = "acquire", + allowDowngrade = false, ) { yield* Effect.annotateCurrentSpan({ reason }); if (yield* Ref.get(desktopState.quitting)) return false; @@ -488,7 +515,11 @@ export const make = Effect.gen(function* () { yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt)); yield* logUpdaterInfo("checking for updates", { reason }); + // The flag is shared updater state, so it flips only while this check holds the reservation. + const restoreDowngrade = yield* electronUpdater.allowDowngrade; + if (allowDowngrade) yield* electronUpdater.setAllowDowngrade(true); return yield* electronUpdater.checkForUpdates.pipe( + Effect.ensuring(electronUpdater.setAllowDowngrade(restoreDowngrade).pipe(Effect.ignore)), Effect.as(true), Effect.catchTags({ ElectronUpdaterCheckForUpdatesError: Effect.fn( @@ -849,8 +880,8 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { const state = yield* Ref.get(updateStateRef); - if (resolveDefaultDesktopUpdateChannel(info.version) !== state.channel) { - yield* logUpdaterInfo("ignoring update that does not match selected channel", { + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", { version: info.version, channel: state.channel, }); @@ -865,6 +896,7 @@ export const make = Effect.gen(function* () { info.releaseNotes, info.version, state.channel, + info.releaseDate, ); yield* setState( reduceDesktopUpdateStateOnUpdateAvailable( @@ -986,6 +1018,20 @@ export const make = Effect.gen(function* () { Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { const state = yield* Ref.get(updateStateRef); + // A download can finish after its track switch moved on; never offer that build. + if (!(yield* offerable(info.version, state))) { + yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", { + version: info.version, + channel: state.channel, + }); + // Its progress events left the updater "downloading", which would block every check. + if (state.status === "downloading") { + yield* setState( + reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp), + ); + } + return; + } yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); yield* logUpdaterInfo("update downloaded", { version: info.version }); }), @@ -1092,9 +1138,17 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.configure")), setChannel: Effect.fn("desktop.updates.setChannel")(function* ( nextChannel: DesktopUpdateChannel, + options?: { readonly minimumVersion?: string | undefined }, ) { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); - const activeAction = yield* tryStartChannelChange; + // A background check holds the updater only briefly, so wait it out instead of refusing. + const activeAction = yield* tryStartChannelChange.pipe( + Effect.repeat({ + while: (active) => Option.isSome(active) && active.value === "check", + schedule: Schedule.spaced(CHANNEL_CHANGE_RETRY_DELAY), + times: CHANNEL_CHANGE_RETRIES, + }), + ); if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value === "install-recovery" ? "install" : activeAction.value, @@ -1103,8 +1157,16 @@ export const make = Effect.gen(function* () { } return yield* Effect.gen(function* () { + yield* Ref.set(releaseFloorRef, Option.fromNullishOr(options?.minimumVersion)); const state = yield* Ref.get(updateStateRef); - if (nextChannel === state.channel) { + const retained = state.downloadedVersion ?? state.availableVersion; + // A build from another track re-checks, so retrying a track switch finds an older release. + // So does a retained offer below the new floor, which must not install. + if ( + nextChannel === state.channel && + nextChannel === desktopUpdateChannelOf(environment.appVersion) && + (retained === null || (yield* offerable(retained, state))) + ) { return state; } @@ -1124,15 +1186,19 @@ export const make = Effect.gen(function* () { } yield* applyAutoUpdaterChannel(nextChannel); - const allowDowngrade = yield* electronUpdater.allowDowngrade; - yield* electronUpdater.setAllowDowngrade(true); - yield* checkForUpdates("channel-change", "held").pipe( - Effect.ensuring(electronUpdater.setAllowDowngrade(allowDowngrade).pipe(Effect.ignore)), + // Only leaving the running build's track can need an older release. + yield* checkForUpdates( + "channel-change", + "held", + nextChannel !== desktopUpdateChannelOf(environment.appVersion), ); return yield* Ref.get(updateStateRef); }).pipe(Effect.ensuring(finishUpdateAction("channel"))); }), - check: Effect.fn("desktop.updates.check")(function* (reason: string) { + check: Effect.fn("desktop.updates.check")(function* ( + reason: string, + options?: { readonly allowDowngrade?: boolean }, + ) { yield* Effect.annotateCurrentSpan({ reason }); if (!(yield* Ref.get(updaterConfiguredRef))) { return { @@ -1140,7 +1206,7 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; } - const checked = yield* checkForUpdates(reason); + const checked = yield* checkForUpdates(reason, "acquire", options?.allowDowngrade === true); return { checked, state: yield* Ref.get(updateStateRef), diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 028e42fa6e3b..d88838439dba 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -28,6 +28,7 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { releaseNotes: [ { version: "0.0.36-nightly.20260828.1213", + releasedAt: "2026-08-28", items: [ "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", "fix(web): keep long task drawers usable on small screens by @human in #8313", @@ -60,6 +61,37 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); + it("reads a generated summary as a note and skips the folded full list", () => { + const fixes = Array.from({ length: 9 }, (_, index) => `- Fix ${index + 1} (#${index + 1})`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + "**v1.2.4** ยท 12 changes from 3 contributors", + "## Highlights", + "Link machines (beta).", + "## Fixes", + ...fixes, + "
", + "Full changelog", + "* fix: raw entry by @human in https://github.com/pingdotgg/t3code/pull/1", + "
", + ].join("\n"), + "1.2.4", + "latest", + "2026-10-29T10:00:00.000Z", + ); + + expect(result.releaseNotes).toEqual([ + { + version: "1.2.4", + releasedAt: "2026-10-29T10:00:00.000Z", + stats: "12 changes from 3 contributors", + intro: "Link machines (beta).", + items: fixes.map((_, index) => `Fix ${index + 1}`), + totalItems: 10, + }, + ]); + }); + it("does not count Markdown or HTML section headings as changes", () => { const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); const result = normalizeDesktopUpdateReleaseNotes( @@ -80,6 +112,35 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ]); }); + it("reads handwritten notes with their own
as a list", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "- Older fix\n- Newer fix\n
Logs\n- hidden\n
", + "1.2.3", + "latest", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.3", items: ["hidden", "Logs", "Newer fix", "Older fix"], totalItems: 4 }, + ]); + }); + + it("dates the offered release from the feed and older nightlies by their build day", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "0.0.41-nightly.20260914.1707", note: "- Nightly change 2" }, + { version: "0.0.41-nightly.20260913.1700", note: "- Nightly change 1" }, + ], + "0.0.41-nightly.20260914.1707", + "nightly", + "2026-09-14T18:30:00.000Z", + ); + + expect(result.releaseNotes.map(({ releasedAt }) => releasedAt)).toEqual([ + "2026-09-14T18:30:00.000Z", + "2026-09-13", + ]); + }); + it("drops releases from other trains before grouping on nightly", () => { const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69d710ff7b26..2704563cfc0b 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -1,6 +1,6 @@ import type { DesktopUpdateChannel, DesktopUpdateReleaseNote } from "@t3tools/contracts"; -import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; interface ElectronReleaseNoteInfo { readonly version: string; @@ -93,11 +93,46 @@ function isIgnoredReleaseNoteLine(line: string): boolean { interface ExtractedReleaseNoteItems { readonly items: ReadonlyArray; readonly totalItems: number; + readonly intro?: string; + readonly stats?: string; +} + +/** + * Reads a generated summary (`scripts/generate-release-notes.ts`): a `v1.2.3 ยท N changesโ€ฆ` + * header, a highlights paragraph, and fix bullets. PR numbers are left to the GitHub page. + */ +function extractReleaseNoteSummary(summary: string): ExtractedReleaseNoteItems { + let stats: string | undefined; + const intro: string[] = []; + const items: string[] = []; + for (const rawLine of stripMarkup(summary).split("\n")) { + const line = rawLine.trim().replace(/\s+/g, " "); + if (line === "" || /^#{1,6}\s+/.test(line)) continue; + if (stats === undefined && /^v\S+ ยท /.test(line)) { + stats = line.slice(line.indexOf(" ยท ") + 3); + continue; + } + const text = line + .replace(/^[-*]\s+/, "") + .replace(/\s*\((?:#\d+(?:,\s*)?)+\)\.?$/, "") + .trim(); + if (/^[-*]\s+/.test(line)) items.push(text); + else intro.push(text); + } + return { + items, + totalItems: items.length + intro.length, + ...(intro.length > 0 ? { intro: intro.join(" ") } : {}), + ...(stats ? { stats } : {}), + }; } function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { if (!note) return { items: [], totalItems: 0 }; + // Generated summaries fold GitHub's full list under this exact
block. + const summaryEnd = note.search(/
\s*\s*Full changelog\s*<\/summary>/i); + if (summaryEnd >= 0) return extractReleaseNoteSummary(note.slice(0, summaryEnd)); const items: string[] = []; let totalItems = 0; for (const rawLine of stripMarkup(note).split("\n")) { @@ -117,6 +152,15 @@ function extractReleaseNoteItems(note: string | null | undefined): ExtractedRele return { items: items.toReversed(), totalItems }; } +// The update feed dates only the offered version; prerelease versions carry their UTC build day. +const PRERELEASE_BUILD_DAY = /-(?:nightly|preview)\.(\d{4})(\d{2})(\d{2})\.\d+$/; + +function resolveReleasedAt(version: string, offeredVersion: string, releaseDate?: string) { + if (version === offeredVersion && releaseDate) return releaseDate; + const day = PRERELEASE_BUILD_DAY.exec(version); + return day ? `${day[1]}-${day[2]}-${day[3]}` : undefined; +} + interface NormalizedDesktopUpdateReleaseNotes { readonly releaseNotes: ReadonlyArray; readonly omittedReleaseCount: number; @@ -127,6 +171,7 @@ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, channel: DesktopUpdateChannel, + releaseDate?: string, ): NormalizedDesktopUpdateReleaseNotes { const rawNotes = ( typeof releaseNotes === "string" @@ -134,16 +179,20 @@ export function normalizeDesktopUpdateReleaseNotes( : Array.isArray(releaseNotes) ? releaseNotes.filter(isElectronReleaseNoteInfo) : [] - ).filter((entry) => resolveDefaultDesktopUpdateChannel(entry.version) === channel); + ).filter((entry) => desktopUpdateChannelOf(entry.version) === channel); const normalizedNotes = rawNotes.flatMap((entry) => { - const { items, totalItems } = extractReleaseNoteItems(entry.note); + const { items, totalItems, intro, stats } = extractReleaseNoteItems(entry.note); if (totalItems === 0) return []; + const releasedAt = resolveReleasedAt(entry.version, fallbackVersion, releaseDate); return [ { version: entry.version, items, totalItems, + ...(intro ? { intro } : {}), + ...(stats ? { stats } : {}), + ...(releasedAt ? { releasedAt } : {}), }, ]; }); diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index d79a374a668a..b07735ceb7a2 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,13 +1,6 @@ -import type { DesktopUpdateChannel } from "@t3tools/contracts"; - -const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; // Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { return PRERELEASE_VERSION_PATTERN.test(version); } - -export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { - return NIGHTLY_VERSION_PATTERN.test(appVersion) ? "nightly" : "latest"; -} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index 1d8c21a42dfe..d8d16b7dbfd6 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -35,6 +35,11 @@ export interface UpdatesHarnessOptions { readonly platform?: NodeJS.Platform; readonly packageType?: string | undefined; readonly installRecord?: string | undefined; + readonly appVersion?: string; + /** Starts from in-memory settings with this track saved, as after a restart. */ + readonly savedChannel?: DesktopAppSettings.DesktopSettings["updateChannel"]; + /** Adds a WSL backend beside the primary, as on a Windows host with WSL enabled. */ + readonly wslBackend?: boolean; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -42,6 +47,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let quitAndInstallCount = 0; let downloadCount = 0; let allowDowngrade = false; + const checkAllowedDowngrade: boolean[] = []; let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); @@ -86,6 +92,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; + checkAllowedDowngrade.push(allowDowngrade); }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.sync(() => { downloadCount += 1; @@ -149,7 +156,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { waitForReady: () => Effect.succeed(true), }; const layerBackend = DesktopBackendPool.layerTest( - options.backendInstances ?? [stubBackendInstance], + options.backendInstances ?? + (options.wslBackend + ? [ + stubBackendInstance, + { + ...stubBackendInstance, + id: DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"), + label: Effect.succeed("Ubuntu"), + }, + ] + : [stubBackendInstance]), ); const layerEnvironment = DesktopEnvironment.layer({ @@ -157,7 +174,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, platform: options.platform ?? "darwin", processArch: "x64", - appVersion: "1.2.3", + appVersion: options.appVersion ?? "1.2.3", appPath: "/repo", isPackaged: true, resourcesPath: "/missing/resources", @@ -178,10 +195,13 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { let testSettings: DesktopAppSettings.DesktopSettings = { ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + ...(options.savedChannel === undefined + ? {} + : { updateChannel: options.savedChannel, updateChannelConfiguredByUser: true }), }; const setUpdateChannelError = options.setUpdateChannelError; const layerSettings = - setUpdateChannelError || options.beforeSetUpdateChannel + setUpdateChannelError || options.beforeSetUpdateChannel || options.savedChannel ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { get: Effect.sync(() => testSettings), load: Effect.sync(() => testSettings), @@ -279,6 +299,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { downloadCount: () => downloadCount, feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, + /** Whether each feed check could offer an older release, and the flag now. */ + checkAllowedDowngrade, + allowDowngrade: () => allowDowngrade, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 36dc282e89b4..e37dd8fb7a89 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -10,6 +10,7 @@ "preview": "astro preview" }, "dependencies": { + "@fontsource-variable/inter": "^5.3.0", "@t3tools/shared": "workspace:*", "astro": "^7.3.5", "sharp": "0.35.4" diff --git a/apps/marketing/public/fonts/dm-sans-OFL.txt b/apps/marketing/public/fonts/dm-sans-OFL.txt deleted file mode 100644 index a0eedfbf0df0..000000000000 --- a/apps/marketing/public/fonts/dm-sans-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt b/apps/marketing/public/fonts/jetbrains-mono-OFL.txt deleted file mode 100644 index 821a3dac22af..000000000000 --- a/apps/marketing/public/fonts/jetbrains-mono-OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. - -This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/marketing/src/assets/app-desktop.webp b/apps/marketing/src/assets/app-desktop.webp index 11b51331eef3..48c0c3e9abc5 100644 Binary files a/apps/marketing/src/assets/app-desktop.webp and b/apps/marketing/src/assets/app-desktop.webp differ diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 deleted file mode 100644 index 6e84cba1949c..000000000000 Binary files a/apps/marketing/src/assets/fonts/dm-sans-latin-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 b/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 deleted file mode 100644 index c75f7bf1b465..000000000000 Binary files a/apps/marketing/src/assets/fonts/dm-sans-latin.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 deleted file mode 100644 index 289de93149ab..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 deleted file mode 100644 index de39acbc4069..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-cyrillic.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 deleted file mode 100644 index a53d52d6d02c..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-greek.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 deleted file mode 100644 index 82f96681c076..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-latin-ext.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 deleted file mode 100644 index 4d09cda4a4c3..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-latin.woff2 and /dev/null differ diff --git a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 b/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 deleted file mode 100644 index e38f5538f84e..000000000000 Binary files a/apps/marketing/src/assets/fonts/jetbrains-mono-vietnamese.woff2 and /dev/null differ diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 6b4bf819e2d1..d1aea12f09bc 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -2,8 +2,7 @@ import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; import socialCard from "../assets/social-card.webp"; -import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; -import "../styles/fonts.css"; +import "@fontsource-variable/inter"; import { ANDROID_PLAY_STORE_URL, GITHUB_REPOSITORY_URL, @@ -58,13 +57,6 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); - @@ -153,22 +145,21 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); diff --git a/apps/marketing/src/styles/fonts.css b/apps/marketing/src/styles/fonts.css deleted file mode 100644 index 2f3933d61239..000000000000 --- a/apps/marketing/src/styles/fonts.css +++ /dev/null @@ -1,193 +0,0 @@ -/*! DM Sans v17 and JetBrains Mono v24. Licenses: /fonts/dm-sans-OFL.txt and /fonts/jetbrains-mono-OFL.txt. */ -/* Keep subset bytes out of the stylesheet so unused glyph ranges do not add transfer size. */ -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin-ext */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "DM Sans"; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../assets/fonts/dm-sans-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* cyrillic-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic-ext.woff2?no-inline) format("woff2"); - unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; -} -/* cyrillic */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-cyrillic.woff2?no-inline) format("woff2"); - unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; -} -/* greek */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-greek.woff2?no-inline) format("woff2"); - unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; -} -/* vietnamese */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-vietnamese.woff2?no-inline) format("woff2"); - unicode-range: - U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, - U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; -} -/* latin-ext */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin-ext.woff2?no-inline) format("woff2"); - unicode-range: - U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, - U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, - U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: "JetBrains Mono"; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../assets/fonts/jetbrains-mono-latin.woff2?no-inline) format("woff2"); - unicode-range: - U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, - U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} diff --git a/apps/mobile/assets/icons/chevron-down.xml b/apps/mobile/assets/icons/chevron-down.xml new file mode 100644 index 000000000000..285eda9c1b46 --- /dev/null +++ b/apps/mobile/assets/icons/chevron-down.xml @@ -0,0 +1,4 @@ + + + diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index 7931717682fa..3d7605b71b5f 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -21,6 +21,7 @@ export interface AndroidHeaderAction { readonly disabled?: boolean; readonly loading?: boolean; readonly selected?: boolean; + readonly subtitle?: string; readonly destructive?: boolean; readonly overflowOnly?: boolean; } @@ -178,10 +179,11 @@ export function AndroidScreenHeader(props: { actions={overflowActions.map((action, index) => ({ id: String(index), title: action.accessibilityLabel, + subtitle: action.subtitle, + state: action.selected ? "on" : undefined, attributes: { disabled: Boolean(action.disabled), destructive: action.destructive, - state: action.selected ? "on" : undefined, }, }))} onPressAction={({ nativeEvent }) => diff --git a/apps/mobile/src/components/ui/MenuPopup.android.tsx b/apps/mobile/src/components/ui/MenuPopup.android.tsx index b5af18cd2bcb..dd6ef2b04656 100644 --- a/apps/mobile/src/components/ui/MenuPopup.android.tsx +++ b/apps/mobile/src/components/ui/MenuPopup.android.tsx @@ -55,7 +55,9 @@ function MenuIcon(props: { ); } -export function MenuPopup(props: MenuPopupProps) { +export function MenuPopupItems( + props: Pick, +) { const { appearance, themeVariables: colors } = useAppearancePreferences(); const { scale, menuItemHeight } = useAndroidControlSizing(); const body = resolveScaledTextRole("body", appearance.baseFontSize); @@ -64,7 +66,7 @@ export function MenuPopup(props: MenuPopupProps) { const muted = colors["--color-foreground-muted"]; // A fixed native item height clips wrapped labels; a minimum lets each row grow. const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })]; - const items = ( + return ( <> {props.parent ? ( @@ -146,6 +148,11 @@ export function MenuPopup(props: MenuPopupProps) { ))} ); +} + +export function MenuPopup(props: MenuPopupProps) { + const { themeVariables: colors } = useAppearancePreferences(); + const items = ; if (props.inline) { return ( ; + const enabled = !props.disabled && !props.loading; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + const buttonColors = { + containerColor: colors["--color-primary"], + contentColor: colors["--color-primary-foreground"], + disabledContainerColor: colors["--color-subtle-strong"], + disabledContentColor: colors["--color-foreground-muted"], + }; + // A usable menu beside a disabled action still takes the disabled palette, so the pair reads as one control. + const trailingColors = enabled + ? buttonColors + : { + ...buttonColors, + containerColor: buttonColors.disabledContainerColor, + contentColor: buttonColors.disabledContentColor, + }; + const label = ( + + {props.loading ? ( + <> + + + + ) : props.icon ? ( + <> + {/* The glyph host consumes touches, so it runs the action itself. */} + + + + + + + + ) : null} + {props.label} + + ); + return ( + + + + {label} + + + setExpanded(false)} + color={colors["--color-card-alt"]} + > + + + + + { + setExpanded(false); + if (action.id !== undefined) props.onPressAction(action.id); + }} + onBack={() => {}} + /> + + + + + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.tsx b/apps/mobile/src/components/ui/SplitButton.tsx new file mode 100644 index 000000000000..511c3a5b7c29 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.tsx @@ -0,0 +1,65 @@ +import { Pressable, View } from "react-native"; +import { AppActivityIndicator } from "./AppActivityIndicator"; +import { AppText } from "./AppText"; +import { SymbolView } from "./AppSymbol"; +import { Menu } from "./Menu"; +import type { SplitButtonProps } from "./SplitButton.types"; + +export function SplitButton(props: SplitButtonProps) { + const active = !props.disabled; + const menuEnabled = props.actions.some((action) => !action.attributes?.disabled); + return ( + + + {props.loading ? ( + + ) : props.icon ? ( + + ) : null} + + {props.label} + + + {props.actions.length > 0 ? ( + + props.onPressAction(nativeEvent.event)} + > + + + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/ui/SplitButton.types.ts b/apps/mobile/src/components/ui/SplitButton.types.ts new file mode 100644 index 000000000000..73d0b36f2ce2 --- /dev/null +++ b/apps/mobile/src/components/ui/SplitButton.types.ts @@ -0,0 +1,14 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { AppSymbolName } from "./AppSymbol"; + +export interface SplitButtonProps { + readonly label: string; + readonly accessibilityLabel?: string; + readonly icon?: AppSymbolName; + readonly disabled?: boolean; + readonly loading?: boolean; + readonly onPress: () => void; + readonly menuTitle: string; + readonly actions: MenuAction[]; + readonly onPressAction: (id: string) => void; +} diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx index 65aaee6f0d47..99aed81cf2b8 100644 --- a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -411,6 +411,7 @@ function OpenDevicePreview({ access={access} platform={session.platform} deviceId={session.deviceId} + serveSimOnly={preview.serveSimOnly} colors={{ background: themeVariables["--color-sheet-solid"], foreground: themeVariables["--color-foreground"], diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx index 798e650b73e2..94db8527c5d6 100644 --- a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -45,6 +45,7 @@ export function DeviceStreamWebView({ access: props.access, platform: props.platform, deviceId: props.deviceId, + ...(props.serveSimOnly ? { serveSimOnly: true } : {}), colors: props.colors, }); return ( diff --git a/apps/mobile/src/features/devices/device-stream-document.ts b/apps/mobile/src/features/devices/device-stream-document.ts index 07a699567b6e..abf045cdd172 100644 --- a/apps/mobile/src/features/devices/device-stream-document.ts +++ b/apps/mobile/src/features/devices/device-stream-document.ts @@ -6,6 +6,8 @@ export interface DeviceStreamConfiguration { readonly access: DeviceHubAccess; readonly platform: DevicePlatform; readonly deviceId: string; + /** See `DeviceStreamTarget.serveSimOnly`. */ + readonly serveSimOnly?: boolean; readonly colors: { readonly background: string; readonly foreground: string; diff --git a/apps/mobile/src/features/devices/threadDevicePreviews.ts b/apps/mobile/src/features/devices/threadDevicePreviews.ts index aaf87ca9784e..5ec92e113b8c 100644 --- a/apps/mobile/src/features/devices/threadDevicePreviews.ts +++ b/apps/mobile/src/features/devices/threadDevicePreviews.ts @@ -14,6 +14,7 @@ export function threadDevicePreviews(state: DeviceServiceState | null, threadId: session, name: device?.name ?? (session.platform === "ios" ? "iOS Simulator" : "Android Emulator"), description: [device?.version, host?.label].filter(Boolean).join(" ยท "), + serveSimOnly: host?.kind === "simlock", }; }); } diff --git a/apps/mobile/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 000000000000..d978333d2732 --- /dev/null +++ b/apps/mobile/src/features/files/fileTree.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vite-plus/test"; + +import { buildFileTree, flattenFileTree } from "./fileTree"; + +it("flattens a wide expanded directory without exceeding argument limits", () => { + const children = Array.from({ length: 200_000 }, (_, index) => ({ + path: `root/file-${index}`, + name: `file-${index}`, + kind: "file" as const, + children: [], + })); + const nodes = [{ path: "root", name: "root", kind: "directory" as const, children }]; + const rows = flattenFileTree({ nodes, expanded: new Set(["root"]) }); + expect(rows).toHaveLength(children.length + 1); + expect(rows[0]).toEqual({ node: nodes[0], depth: 0 }); + const middleChildIndex = Math.floor(children.length / 2); + expect(rows[middleChildIndex + 1]).toEqual({ + node: children[middleChildIndex], + depth: 1, + }); + expect(rows.at(-1)).toEqual({ node: children.at(-1), depth: 1 }); +}); + +it("prunes unmatched subtrees without losing adjacent rows or their depths", () => { + const nodes = buildFileTree([ + { kind: "file", path: "root/a/hidden.txt" }, + { kind: "file", path: "root/b/match.txt" }, + { kind: "file", path: "root/b/other.txt" }, + { kind: "file", path: "root/c/hidden.txt" }, + { kind: "file", path: "tail.txt" }, + ]); + const expanded = new Set(["root", "root/a", "root/b", "root/c"]); + const rows = (searchResultPaths: ReadonlySet | null) => + flattenFileTree({ nodes, expanded, searchResultPaths }).map( + ({ node, depth }) => `${depth}:${node.path}`, + ); + + expect(rows(new Set(["root/b/match.txt", "tail.txt"]))).toEqual([ + "0:root", + "1:root/b", + "2:root/b/match.txt", + "0:tail.txt", + ]); + expect(rows(new Set(["root/b"]))).toEqual(["0:root", "1:root/b"]); + expect(rows(new Set())).toEqual([]); + expect(rows(null)).toEqual([ + "0:root", + "1:root/a", + "2:root/a/hidden.txt", + "1:root/b", + "2:root/b/match.txt", + "2:root/b/other.txt", + "1:root/c", + "2:root/c/hidden.txt", + "0:tail.txt", + ]); + expect(flattenFileTree({ nodes, expanded: new Set() }).map(({ node }) => node.path)).toEqual([ + "root", + "tail.txt", + ]); +}); diff --git a/apps/mobile/src/features/files/fileTree.ts b/apps/mobile/src/features/files/fileTree.ts index 4e4b4b77171d..22e8e9bd5bd8 100644 --- a/apps/mobile/src/features/files/fileTree.ts +++ b/apps/mobile/src/features/files/fileTree.ts @@ -100,11 +100,12 @@ function flattenNode( const isSearching = searchResultPaths !== null; const matches = searchResultPaths?.has(node.path) ?? false; let descendantMatches = false; - const childOutput: VisibleFileTreeNode[] = []; + const start = output.length; + output.push({ node, depth }); if (node.kind === "directory" && (expanded.has(node.path) || isSearching)) { for (const child of node.children) { - if (flattenNode(childOutput, child, depth + 1, expanded, searchResultPaths)) { + if (flattenNode(output, child, depth + 1, expanded, searchResultPaths)) { descendantMatches = true; } } @@ -112,11 +113,10 @@ function flattenNode( const visible = !isSearching || matches || descendantMatches; if (!visible) { + output.length = start; return false; } - output.push({ node, depth }); - output.push(...childOutput); return matches || descendantMatches; } diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index b784bdb4eb42..8ba68d2a2bfa 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,4 +1,3 @@ -import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -6,12 +5,10 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; -import { SymbolView } from "../../components/ui/AppSymbol"; -import { Menu } from "../../components/ui/Menu"; import { Button } from "../../components/ui/Button"; -import { IconButton } from "../../components/ui/IconButton"; +import { SplitButton } from "../../components/ui/SplitButton"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -26,8 +23,8 @@ import { import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; import { usePullRequestPermissions } from "./usePullRequestPermissions"; -import type { PullRequestStackState } from "./usePullRequestStack"; import { PullRequestGlyph } from "./pullRequestGlyphs"; +import type { PullRequestStackState } from "./usePullRequestStack"; export function PullRequestMergeControls(props: { readonly pr: PullRequestDetail; @@ -169,107 +166,41 @@ export function PullRequestMergeControls(props: { }; return ( - - {Platform.OS === "android" ? ( - - + ), + useWindowDimensions: () => ({ height: 800 }), +})); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) })); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock("../../../components/ui/AndroidScreenHeader", () => ({ AndroidSheetHeader: () => null })); +vi.mock("../../../components/ui/SheetHeader", () => ({ SheetHeader: () => null })); +vi.mock("../../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../../native/StackHeader", () => ({ NativeStackScreenOptions: () => null })); +vi.mock("../../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => {children}, + AppTextInput: () => null, +})); +vi.mock("./gitSheetComponents", () => ({ SheetActionButton: () => null })); +vi.mock("../../../state/entities", () => ({})); +vi.mock("../../../state/threads", () => ({})); +vi.mock("../../../state/use-thread-selection", () => ({ + useThreadSelection: () => ({ + selectedThread: { environmentId: "environment-1", worktreePath: checkout.worktreePath }, + selectedThreadProject: { workspaceRoot: checkout.projectRoot }, + }), +})); +vi.mock("../../../state/use-thread-detail", () => ({ + useSelectedThreadWorktreePath: () => checkout.worktreePath, +})); +vi.mock("../../../state/use-selected-thread-git-state", () => ({ + useSelectedThreadGitState: () => ({ + selectedThreadBranches: checkout.branches, + selectedThreadBranchesLoading: false, + gitOperationLabel: null, + }), +})); +vi.mock("../../../state/use-selected-thread-git-actions", () => ({ + useSelectedThreadGitActions: () => ({ canChangeThreadBranch: true }), +})); +vi.mock("../../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); +vi.mock("../../../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); + +import { GitBranchesSheet } from "./GitBranchesSheet"; + +it("recognizes both the main checkout and a linked worktree as this thread's checkout", () => { + const directory = "/repo"; + const linked = "/repo-linked"; + checkout.projectRoot = directory; + checkout.branches = [ + { name: "feature/available", worktreePath: null }, + { name: "feature/linked", worktreePath: linked }, + { name: "main", worktreePath: directory }, + ]; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const rows = () => { + act(() => { + root.render( + , + ); + }); + return [...container.querySelectorAll("button")].map((button) => ({ + name: button.querySelectorAll("span")[0]?.textContent, + subtitle: button.querySelectorAll("span")[1]?.textContent, + disabled: button.disabled, + })); + }; + try { + checkout.worktreePath = null; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in another worktree", disabled: true }, + { name: "main", subtitle: "Checked out in this thread", disabled: false }, + ]); + + checkout.worktreePath = linked; + expect(rows()).toEqual([ + { name: "feature/available", subtitle: "Local branch", disabled: false }, + { name: "feature/linked", subtitle: "Checked out in this thread", disabled: false }, + { name: "main", subtitle: "Checked out in another worktree", disabled: true }, + ]); + } finally { + act(() => root.unmount()); + } +}); diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 4075a49e9774..dcaf96818bfe 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -28,7 +28,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const { selectedThread } = useThreadSelection(); - const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const { canChangeThreadBranch } = gitActions; @@ -43,7 +43,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; - const currentWorktreePath = selectedThreadWorktreePath; + const currentWorktreePath = selectedThreadCwd; const availableBranches = gitState.selectedThreadBranches; const branchesLoading = gitState.selectedThreadBranchesLoading; const busy = gitState.gitOperationLabel !== null; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index c2e1858b7d78..736fb09d0b61 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,4 +1,6 @@ import { ActiveNewTaskContext } from "../../state/active-new-task"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; +import { useNowMinuteMs } from "../../lib/clock"; import * as Dialog from "../../components/ui/Dialog"; import React, { useEffect, useRef, useState } from "react"; @@ -492,6 +494,7 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); + const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, @@ -510,12 +513,24 @@ export function NewTaskFlowProvider( selectedProject?.id ?? null, ); - const selectedModel = resolveNewTaskModelSelection({ + const selectedDefaultModel = resolveNewTaskModelSelection({ draftSelection: draftModelSelection, projectDefaultSelection: projectDefaultModelSelection, stickySelection: stickyModelSelection, modelOptions, }); + const selectedModel = resolveNewThreadModelSelection({ + defaultSelection: selectedDefaultModel, + explicitSelection: + draftModelSelection ?? (projectDefaultModelSelection ? null : stickyModelSelection), + fallbackSelection: selectedEnvironmentServerConfig?.settings.fallbackModelSelection ?? null, + providers: selectedEnvironmentServerConfig?.providers ?? [], + ...(selectedEnvironmentServerConfig + ? { settings: selectedEnvironmentServerConfig.settings } + : {}), + projectId: selectedProject?.id ?? null, + now: modelSelectionNow, + }).selection; const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 314dc1aa477b..3d9463c79bd0 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -114,14 +114,17 @@ describe("pending timeline messages", () => { expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]); }); - it("stops showing an acknowledged message once the projection holds it, even when the feed hides it", () => { + it("stops showing an acknowledged message once the thread catches up, even when the feed hides it", () => { // A rolled-back or cancelled queued run keeps its message in projection.messages // while its user_message item leaves the visible feed. const echoed = pending("rolled-back"); - const waiting = pending("waiting"); - const shown = selectPendingAcknowledgedMessages([echoed, waiting], { + // A bounded projection pages an applied message out; it must not come back as pending. + const pagedOut = { ...pending("paged-out"), sequence: 40 }; + const waiting = { ...pending("waiting"), sequence: 50 }; + const shown = selectPendingAcknowledgedMessages([echoed, pagedOut, waiting], { threadKey: "env:thread", queuedMessages: [], + sequence: 45, projectedMessages: [{ id: echoed.messageId }], }); expect(shown).toEqual([waiting]); diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index 9eaf42dcee81..d38cc026fdd6 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -5,7 +5,11 @@ import { } from "@t3tools/contracts"; import type { ThreadFeedEntry } from "../../lib/threadActivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { + AcknowledgedThreadMessage, + QueuedThreadMessage, +} from "../../state/thread-outbox-model"; /** * A guest's message waiting for the owner's Send or Dismiss. It is a feed row, not the list @@ -24,21 +28,25 @@ export type PendingThreadFeedEntry = ThreadFeedEntry & { readonly acknowledged?: boolean; }; -// An acknowledged row stops being pending once the projection holds its message, +// An acknowledged row stops being pending once the thread catches up to it, // even when the feed hides that message (a rolled-back or cancelled run). export function selectPendingAcknowledgedMessages( - acknowledged: ReadonlyArray, + acknowledged: ReadonlyArray, thread: { readonly threadKey: string | null; readonly queuedMessages: ReadonlyArray; + readonly sequence: number; readonly projectedMessages: ReadonlyArray<{ readonly id: string }> | undefined; }, -): ReadonlyArray { +): ReadonlyArray { return acknowledged.filter( (message) => scopedThreadKey(message.environmentId, message.threadId) === thread.threadKey && !thread.queuedMessages.some((queued) => queued.messageId === message.messageId) && - !thread.projectedMessages?.some((projected) => projected.id === message.messageId), + !threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: thread.projectedMessages, + }), ); } diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index eba94a7e4e14..3a8f56ec5e8f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -64,6 +64,8 @@ import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; +import { canTurnThreadIntoProject, turnThreadIntoProject } from "./turnThreadIntoProject"; +import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, resolveThreadListV2SnoozeGateExpiryMs, @@ -874,6 +876,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : [], [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); + const canTurnIntoProject = canTurnThreadIntoProject(thread); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -881,8 +884,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...(canTurnIntoProject + ? [ + { + id: "turn-into-project", + title: "Turn into project", + image: "folder.badge.plus", + attributes: { disabled: !threadCanArchive(thread.runtime) }, + } satisfies MenuAction, + ] + : []), + ], + [ + canTurnIntoProject, + props.titleRegenerationSupported, + thread.runtime, + thread.titleRegeneration, ], - [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( () => [ @@ -960,6 +978,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "turn-into-project") void turnThreadIntoProject(thread); if (nativeEvent.event === "copy-thread") { const target = threadChipCopyTarget( { environmentId: thread.environmentId, threadId: thread.id }, @@ -1359,8 +1378,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {props.project ? ( diff --git a/apps/mobile/src/features/threads/thread-route-hydration.test.ts b/apps/mobile/src/features/threads/thread-route-hydration.test.ts index 9d8ce88506fd..9a7f1043609d 100644 --- a/apps/mobile/src/features/threads/thread-route-hydration.test.ts +++ b/apps/mobile/src/features/threads/thread-route-hydration.test.ts @@ -1,6 +1,11 @@ +import * as Option from "effect/Option"; import { describe, expect, it } from "vite-plus/test"; -import { threadRouteIsHydrating } from "./thread-route-hydration"; +import { + threadDetailErrorIsCurrent, + threadRouteIsHydrating, + threadRouteOpensDraft, +} from "./thread-route-hydration"; const settled = { isLoadingConnections: false, @@ -89,3 +94,62 @@ describe("threadRouteIsHydrating", () => { ).toBe(false); }); }); + +describe("threadRouteOpensDraft", () => { + it("keeps the route while a reconnecting environment may still deliver the thread", () => { + expect(threadRouteOpensDraft({ connectionState: "reconnecting", detailStatus: "empty" })).toBe( + false, + ); + }); + + it("keeps the route while Reconnect retries a thread that failed to sync", () => { + const failedDetail = { + ...settled, + connectionState: "connecting" as const, + detailStatus: "empty" as const, + detailHasError: true, + }; + expect(threadRouteIsHydrating(failedDetail)).toBe(false); + expect(threadRouteOpensDraft(failedDetail)).toBe(false); + }); + + it("opens a draft once the thread can't arrive", () => { + expect( + threadRouteOpensDraft({ connectionState: "reconnecting", detailStatus: "deleted" }), + ).toBe(true); + expect(threadRouteOpensDraft({ connectionState: "offline", detailStatus: "empty" })).toBe(true); + }); +}); + +describe("threadDetailErrorIsCurrent", () => { + const oldError = Option.some("Could not synchronize the thread."); + + it("ignores the error a thread kept from before the connection came back", () => { + const detailHasError = threadDetailErrorIsCurrent({ + connectionState: "connected", + error: oldError, + errorBeforeConnected: oldError, + }); + expect(detailHasError).toBe(false); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "empty", detailHasError })).toBe( + true, + ); + }); + + it("counts an error the live connection reported", () => { + expect( + threadDetailErrorIsCurrent({ + connectionState: "connected", + error: Option.some("Could not synchronize the thread."), + errorBeforeConnected: oldError, + }), + ).toBe(true); + expect( + threadDetailErrorIsCurrent({ + connectionState: "reconnecting", + error: oldError, + errorBeforeConnected: oldError, + }), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-route-hydration.ts b/apps/mobile/src/features/threads/thread-route-hydration.ts index 83b5fc939d03..a40f0d1f100a 100644 --- a/apps/mobile/src/features/threads/thread-route-hydration.ts +++ b/apps/mobile/src/features/threads/thread-route-hydration.ts @@ -1,6 +1,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; +import * as Option from "effect/Option"; export function threadRouteIsHydrating(input: { readonly isLoadingConnections: boolean; @@ -42,3 +43,34 @@ export function threadRouteIsHydrating(input: { (input.connectionState === "connected" && input.detailStatus === "empty") ); } + +/** + * Replacing the route with a draft can't be undone, so a thread that may still arrive keeps its + * route: a reconnecting environment often delivers it moments later, and Reconnect starts a + * fresh connecting attempt. + */ +export function threadRouteOpensDraft(input: { + readonly connectionState: EnvironmentConnectionPhase; + readonly detailStatus: EnvironmentThreadStatus; +}): boolean { + if (input.detailStatus === "deleted") { + return true; + } + return input.connectionState !== "reconnecting" && input.connectionState !== "connecting"; +} + +/** + * A thread keeps its sync error until the new connection resubscribes, so the first renders after + * a reconnect still show the old connection's error. Only an error the live connection reported + * counts. + */ +export function threadDetailErrorIsCurrent(input: { + readonly connectionState: EnvironmentConnectionPhase; + readonly error: Option.Option; + readonly errorBeforeConnected: Option.Option; +}): boolean { + return ( + Option.isSome(input.error) && + !(input.connectionState === "connected" && input.error === input.errorBeforeConnected) + ); +} diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d8dde459792d..7f560497da25 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -8,6 +8,7 @@ import { type RuntimeSubagent, type SubagentWorkflowGroup, } from "@t3tools/client-runtime/state/subagentRuntime"; +import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId, OrchestrationV2Subagent, @@ -166,8 +167,6 @@ export function nextWorkflowSelection( ); } -const EFFORT_OPTION_IDS = new Set(["reasoningEffort", "effort", "reasoning", "variant"]); - function capitalize(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } @@ -185,7 +184,7 @@ export function workflowMemberMetadata( const effortChoice = provider?.models .find((candidate) => candidate.slug === slug) ?.capabilities?.optionDescriptors?.flatMap((descriptor) => - descriptor.type === "select" && EFFORT_OPTION_IDS.has(descriptor.id) + descriptor.type === "select" && REASONING_EFFORT_OPTION_IDS.includes(descriptor.id) ? descriptor.options : [], ) diff --git a/apps/mobile/src/features/threads/turnThreadIntoProject.ts b/apps/mobile/src/features/threads/turnThreadIntoProject.ts new file mode 100644 index 000000000000..44e6e03fe0d9 --- /dev/null +++ b/apps/mobile/src/features/threads/turnThreadIntoProject.ts @@ -0,0 +1,47 @@ +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; + +import * as Dialog from "../../components/ui/Dialog"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentProjects, projectEnvironment } from "../../state/projects"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; + +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(thread: EnvironmentThreadShell): boolean { + if (thread.worktreePath === null) return false; + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId); + if (config?.environment.capabilities.threadProjectFromScratch !== true) return false; + const project = appAtomRegistry.get( + environmentProjects.projectAtom({ + environmentId: thread.environmentId, + projectId: thread.projectId, + }), + ); + return project?.isScratch === true; +} + +export async function turnThreadIntoProject(thread: EnvironmentThreadShell): Promise { + const title = "Could not turn thread into a project"; + if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) { + Dialog.alert(title, "This connection cannot change threads."); + return; + } + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const result = await runAtomCommand( + appAtomRegistry, + projectEnvironment.createFromThread, + { environmentId: thread.environmentId, input: { threadId: thread.id } }, + { reportFailure: false }, + ); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert(title, error instanceof Error ? error.message : "Try again."); + } +} diff --git a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx index dedfed547c8e..f38c4f05825a 100644 --- a/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx +++ b/apps/mobile/src/features/voice-input/ComposerDictationControl.tsx @@ -282,7 +282,8 @@ function VoiceActionButton(props: { style={{ opacity: props.disabled && !props.loading ? 0.4 : 1, // Matches ComposerActionButton so the circle stays concentric with the large composer corner. - width: variant === "primary" && large ? 62 : undefined, + // An undefined width would still override the class's 44px width. + ...(variant === "primary" && large ? { width: 62 } : null), }} > (null); // The Settings list takes the selected tab's place, so search follows it. const selectedTab = workspace.settingsOpen ? "settings" : workspace.state.selectedTab; @@ -189,9 +189,9 @@ export function WorkspaceToolbar(props: { {composeButton} {selectedTab !== "threads" || isDuoDevice(deviceType) || - deviceType === "pad" || + (deviceType === "pad" && layout.usesSplitView) || sidebarTabs === "picker" || - compactTabs ? null : ( + floatingTabs ? null : ( { width: 4032, height: 3024, }; + type Size = { width: number; height: number }; const native = { - size: { width: 1, height: 1 }, - resizes: [] as Array<{ width?: number | null; height?: number | null }>, + size: { width: 1, height: 1 } as Size, + resizes: [] as Size[], saved: rendered as { uri: string; base64?: string }, + encode: null as ((size: Size, format: string) => { uri: string; base64?: string }) | null, }; beforeEach(() => { native.size = { width: 1, height: 1 }; native.resizes = []; native.saved = rendered; + native.encode = null; mocks.manipulate.mockReset(); mocks.release.mockReset(); - mocks.manipulate.mockImplementation(() => { + mocks.manipulate.mockImplementation((source: string | Size) => { + let size = typeof source === "string" ? native.size : source; const context = { - resize(size: { width?: number | null; height?: number | null }) { - native.resizes.push(size); + resize(next: Size) { + native.resizes.push(next); + size = next; return context; }, renderAsync: async () => ({ - ...native.size, + width: size.width, + height: size.height, release: mocks.release, - saveAsync: async () => native.saved, + saveAsync: async (options: { format: string }) => + native.encode?.(size, options.format) ?? native.saved, }), }; return context; @@ -210,6 +217,45 @@ describe("composer file attachments", () => { ]); }); + it("shrinks a fitted PNG further when re-encoding it exceeds the image limit", async () => { + const header = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(header); + header.write("IHDR", 12); + header.writeUInt32BE(1668, 16); + header.writeUInt32BE(2388, 20); + native.size = { width: 1668, height: 2388 }; + // Native PNG encoding of the 2000px resample is larger than the 7 MB original. + native.encode = (size, format) => ({ + uri: `file:///cache/fitted-${size.width}x${size.height}.${format}`, + base64: Buffer.alloc(size.width * size.height * 4).toString("base64"), + }); + mocks.size.mockReturnValue(7 * 1024 * 1024); + mocks.readBase64.mockResolvedValue(header.toString("base64")); + mocks.pickMedia.mockResolvedValue({ + canceled: false, + assets: [{ ...photo, fileName: "IMG_9280.png", mimeType: "image/png" }], + }); + + const result = await pickComposerImages({ existingCount: 0 }); + + expect(result.error).toBeNull(); + const [image] = result.images; + const last = native.resizes.at(-1)!; + expect(native.resizes[0]).toEqual({ width: 1397, height: 2000 }); + expect(Math.max(last.width, last.height)).toBeLessThan(2000); + expect(image).toEqual( + expect.objectContaining({ + name: "IMG_9280.png", + mimeType: "image/png", + sizeBytes: last.width * last.height * 4, + previewUri: `file:///cache/fitted-${last.width}x${last.height}.png`, + }), + ); + expect(image!.sizeBytes).toBeLessThanOrEqual(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + expect(mocks.delete).toHaveBeenCalledWith("file:///cache/fitted-1397x2000.png"); + expect(mocks.delete).not.toHaveBeenCalledWith(image!.previewUri); + }); + it("measures the picker file instead of trusting the reported size", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, diff --git a/apps/mobile/src/lib/composerImageFit.ts b/apps/mobile/src/lib/composerImageFit.ts index fddeac277bcf..18cd93b3fe72 100644 --- a/apps/mobile/src/lib/composerImageFit.ts +++ b/apps/mobile/src/lib/composerImageFit.ts @@ -1,3 +1,4 @@ +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import { downscalableImageMimeType, fitImageAttachmentSize, @@ -6,8 +7,24 @@ import { IMAGE_DIMENSIONS_HEADER_BYTES, readImageDimensions, } from "@t3tools/shared/imageDimensions"; +import { estimateBase64ByteSize } from "./base64"; const PHOTO_JPEG_QUALITY = 0.85; +const MAX_BYTE_LIMIT_SHRINKS = 3; + +function shrinkToByteLimit( + size: { readonly width: number; readonly height: number }, + bytes: number, +): { width: number; height: number } | null { + if (bytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) return null; + // Encoded size tracks pixel count, so scale each edge by the square root with some headroom. + const scale = Math.sqrt(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / bytes) * 0.9; + const next = { + width: Math.max(1, Math.floor(size.width * scale)), + height: Math.max(1, Math.floor(size.height * scale)), + }; + return next.width < size.width || next.height < size.height ? next : null; +} export async function renderPhoto( uri: string, @@ -16,21 +33,35 @@ export async function renderPhoto( const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator"); let image = await ImageManipulator.manipulate(uri).renderAsync(); try { - const fittedSize = fitImageAttachmentSize(image); - if (fittedSize !== null) { - const resized = await ImageManipulator.manipulate(image).resize(fittedSize).renderAsync(); - image.release(); - image = resized; - } - const saved = await image.saveAsync({ - format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, - compress: PHOTO_JPEG_QUALITY, - base64: true, - }); - if (!saved.base64) { - throw new Error("The rendered photo has no bytes."); + let size = fitImageAttachmentSize(image); + for (let shrinks = 0; ; shrinks += 1) { + if (size !== null) { + const resized = await ImageManipulator.manipulate(image).resize(size).renderAsync(); + image.release(); + image = resized; + } + const saved = await image.saveAsync({ + format: format === "png" ? SaveFormat.PNG : SaveFormat.JPEG, + compress: PHOTO_JPEG_QUALITY, + base64: true, + }); + if (!saved.base64) { + throw new Error("The rendered photo has no bytes."); + } + // PNG ignores compress, so a resampled PNG can encode larger than its original. + size = + shrinks < MAX_BYTE_LIMIT_SHRINKS + ? shrinkToByteLimit(image, estimateBase64ByteSize(saved.base64)) + : null; + if (size === null) return { base64: saved.base64, uri: saved.uri }; + try { + const { File } = await import("expo-file-system"); + const discarded = new File(saved.uri); + if (discarded.exists) discarded.delete(); + } catch (error) { + console.warn("[composer-images] could not remove discarded render", error); + } } - return { base64: saved.base64, uri: saved.uri }; } finally { image.release(); } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5060f359ebcd..96626a5caefa 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -24,6 +24,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadFeed, + workEntryRowLabel, threadFeedCompactionLabels, createThreadFeedCache, deriveThreadFeedPresentation, @@ -31,16 +32,30 @@ import { type ThreadFeedCache, type ThreadFeedEntry, type ThreadFeedLatestRun, - togglePendingUserInputOptionSelection, - setPendingUserInputCustomAnswer, - isPendingUserInputOptionSelected, - buildPendingUserInputAnswers, } from "./threadActivity"; const threadId = ThreadId.make("thread-1"); const sourceThreadId = ThreadId.make("thread-source"); const runId = RunId.make("run-1"); +it("shows a captured MCP app for a failed deferred write", () => { + const mcpApp = { + attachmentId: "deferred-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const item: OrchestrationV2TurnItem = { + ...base("deferred-app", "2026-08-29T00:00:00.000Z", 1), + type: "dynamic_tool", + toolName: "github.create_pull_request", + status: "failed", + input: {}, + output: { t3McpApp: mcpApp, result: { content: [], isError: true } }, + }; + expect(buildThreadFeed([projected(item, 0)])).toMatchObject([{ type: "mcp-app", app: mcpApp }]); +}); + it("keeps historical plan detail accessible from its paged turn item", () => { const item = { ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1), @@ -735,6 +750,40 @@ describe("buildThreadFeed", () => { expect(activities.at(-1)?.prominent).toBe(true); }); + it.each([ + ["local", undefined], + ["inherited", undefined], + ["synthetic", undefined], + ["synthetic", "Forked from conversation"], + ] as const)( + "labels a %s fork row with title %s without exposing its target ID", + (visibility, title) => { + const { providerThreadId: _providerThreadId, ...forkBase } = base( + "item-fork", + "2026-06-20T00:00:03.000Z", + 2, + ); + const activity = buildThreadFeed([ + projected( + { + ...forkBase, + type: "fork", + title: title ?? null, + source: { type: "run", threadId: sourceThreadId, runId }, + targetThreadId: threadId, + }, + 0, + visibility, + ), + ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0]!; + + expect(workEntryRowLabel(activity.workEntry)).toBe(title ?? "Thread forked"); + expect(workEntryRowLabel(activity.workEntry, true)).toBe(title ?? "Thread forked"); + expect(activity.canExpand).toBe(true); + expect(activity.getFullDetail()).toContain(threadId); + }, + ); + it("names the fold that hides a progress message, with or without a run", () => { const middle = { ...assistantMessage("2026-06-20T00:00:02.500Z"), @@ -1368,17 +1417,6 @@ describe("retained v2 feed presentation", () => { }); }); -const singleSelectQuestion = { - id: "runtime", - header: "Runtime", - question: "Which runtime should be used?", - options: [ - { label: "Go", description: "One binary" }, - { label: "Node.js", description: "Reuse TypeScript" }, - ], - multiSelect: false, -} as const; - describe("subagent continued by a later turn", () => { const run2 = RunId.make("run-2"); const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`; @@ -1454,95 +1492,6 @@ describe("subagent continued by a later turn", () => { }); }); -describe("pending user input answers", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: question.initialAnswer }, - }), - ).toEqual({ runtime: question.initialAnswer }); - expect( - buildPendingUserInputAnswers([question], { - runtime: { customAnswer: " Edited message\n\n" }, - }), - ).toEqual({ runtime: " Edited message\n\n" }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({ - runtime: "", - }); - expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({ - runtime: " \n", - }); - expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual( - { customAnswer: "" }, - ); - }); -}); - -describe("provider question values", () => { - const question = { - ...singleSelectQuestion, - allowCustomAnswer: false, - options: [ - { label: "Same label", value: " exact first ", description: "First" }, - { label: "Same label", value: "second", description: "Second" }, - ], - } as const; - - it("submits raw option values and distinguishes duplicate labels", () => { - const first = togglePendingUserInputOptionSelection(question, undefined, " exact first "); - expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true); - expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false); - expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({ - runtime: " exact first ", - }); - expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first); - }); - - it("rejects arbitrary text when the provider only accepts offered options", () => { - expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({}); - expect( - buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([question], { - runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, - }), - ).toEqual({ runtime: "second" }); - }); -}); - -it("accepts ready attachment-only answers while preserving selected options", () => { - const question = { - id: "q", - header: "Spec", - question: "Provide a specification", - options: [{ label: "Yes", description: "Approve" }], - multiSelect: false, - }; - expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({ - q: "", - }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, selectedOptionValues: ["Yes"] }, - }), - ).toEqual({ q: "Yes" }); - expect( - buildPendingUserInputAnswers([question], { - q: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], { - q: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fc085a7c50a0..bd7af44b2fa0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,7 +1,6 @@ import type { ThreadPendingApproval, ThreadPendingUserInput, - ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; @@ -81,13 +80,6 @@ import { export type PendingApproval = ThreadPendingApproval; export type PendingUserInput = ThreadPendingUserInput; -export interface PendingUserInputDraftAnswer { - readonly selectedOptionValues?: ReadonlyArray; - readonly customAnswer?: string; - readonly attachmentCount?: number; - readonly attachmentsBlocked?: boolean; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; @@ -261,6 +253,7 @@ function compactWorkEntryText(value: string): string { } export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = false): string { + if (entry.itemType === "fork") return entry.label; if (expanded && entry.itemType === "reasoning") return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; const presentation = resolveWorkEntryToolPresentation(entry); @@ -406,73 +399,6 @@ function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function resolvePendingUserInputOptionValue( - question: ThreadUserInputQuestion, - value: string, -): string | null { - if (question.options.some((option) => option.value === value)) { - return value; - } - - const label = value.trim(); - return label.length > 0 && - question.options.some((option) => option.value === undefined && option.label.trim() === label) - ? label - : null; -} - -function normalizeSelectedOptionValues( - question: ThreadUserInputQuestion, - value: ReadonlyArray | undefined, -): ReadonlyArray { - if (!Array.isArray(value)) { - return []; - } - - return Array.from( - new Set( - value - .map((entry) => resolvePendingUserInputOptionValue(question, entry)) - .filter((entry): entry is string => entry !== null), - ), - ); -} - -function resolvePendingUserInputAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | ReadonlyArray | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - function capitalizePhrase(value: string): string { const trimmed = value.trim(); return trimmed.length === 0 ? value : `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; @@ -1974,93 +1900,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo return activity.detail ?? activity.summary; } -export function setPendingUserInputCustomAnswer( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, -): PendingUserInputDraftAnswer { - if (question.allowCustomAnswer === false) { - return draft ?? {}; - } - - const selectedOptionValues = - question.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - -export function isPendingUserInputOptionSelected( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): boolean { - if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { - return false; - } - - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - return ( - resolvedOptionValue !== null && - normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( - resolvedOptionValue, - ) - ); -} - -export function togglePendingUserInputOptionSelection( - question: ThreadUserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); - if (resolvedOptionValue === null) { - return draft ?? {}; - } - - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues( - question, - draft?.selectedOptionValues, - ); - const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) - ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) - : [...selectedOptionValues, resolvedOptionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [resolvedOptionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record> | null { - const answers: Record> = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - interface ThreadFeedBuild { readonly rows: ReadonlyArray; readonly attempts: ReadonlyArray | undefined; @@ -2263,10 +2102,7 @@ export function buildThreadFeed( entryRows.push(index); continue; } - const app = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const app = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (app) { const entry: RawThreadFeedEntry = { type: "mcp-app", diff --git a/apps/mobile/src/state/acknowledged-thread-messages.ts b/apps/mobile/src/state/acknowledged-thread-messages.ts index ab9f26dd7d41..d7935d1debca 100644 --- a/apps/mobile/src/state/acknowledged-thread-messages.ts +++ b/apps/mobile/src/state/acknowledged-thread-messages.ts @@ -1,31 +1,39 @@ import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "./atom-registry"; -import type { QueuedThreadMessage } from "./thread-outbox-model"; +import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import type { AcknowledgedThreadMessage, QueuedThreadMessage } from "./thread-outbox-model"; import { environmentThreadDetails } from "./threads"; -// Keep acknowledged rows until their subscribed projection arrives; command receipts can precede messages. -// Render hides rows the projection has echoed, so dropping them here only frees memory. -export const acknowledgedThreadMessagesAtom = Atom.make>( +// Keep acknowledged rows until the thread catches up; command receipts can precede messages. +export const acknowledgedThreadMessagesAtom = Atom.make>( [], ).pipe(Atom.keepAlive); // Peeks at a thread only when something already has it in the registry: reading // it through the registry would open a snapshot load and a live stream of its own. -function isEchoedInMountedThread(message: QueuedThreadMessage): boolean { - const threadAtom = environmentThreadDetails.threadAtom({ +function isEchoedInMountedThread(message: AcknowledgedThreadMessage): boolean { + const stateAtom = environmentThreadDetails.stateAtom({ environmentId: message.environmentId, threadId: message.threadId, }); - const node = appAtomRegistry.getNodes().get(threadAtom); + const node = appAtomRegistry.getNodes().get(stateAtom); if (node === undefined) return false; - const loaded: Option.Option> = node.valueOption(); - const thread = Option.getOrNull(loaded); - return thread?.projection.messages.some((entry) => entry.id === message.messageId) ?? false; + const loaded: Option.Option> = node.valueOption(); + return Option.match(loaded, { + onNone: () => false, + onSome: (thread) => + threadHasCaughtUpTo(message, { + sequence: thread.sequence, + messages: Option.getOrUndefined(thread.data)?.messages, + }), + }); } function writeAcknowledgedThreadMessages( - update: (current: ReadonlyArray) => ReadonlyArray, + update: ( + current: ReadonlyArray, + ) => ReadonlyArray, ) { const current = appAtomRegistry.get(acknowledgedThreadMessagesAtom); const next = update(current).filter((entry) => !isEchoedInMountedThread(entry)); @@ -35,7 +43,7 @@ function writeAcknowledgedThreadMessages( appAtomRegistry.set(acknowledgedThreadMessagesAtom, next); } -export function retainAcknowledgedThreadMessage(message: QueuedThreadMessage) { +export function retainAcknowledgedThreadMessage(message: AcknowledgedThreadMessage) { writeAcknowledgedThreadMessages((current) => current.some((entry) => entry.messageId === message.messageId) ? current diff --git a/apps/mobile/src/state/queued-run-edit.test.ts b/apps/mobile/src/state/queued-run-edit.test.ts index 361a743ac71b..6670505ad398 100644 --- a/apps/mobile/src/state/queued-run-edit.test.ts +++ b/apps/mobile/src/state/queued-run-edit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { Atom } from "effect/reactivity"; import { ChatAttachmentId, @@ -20,7 +20,25 @@ import { type QueuedRunEdit, } from "./queued-run-edit"; import { threadComposerErrorsAtom } from "./thread-composer-error"; -import { getComposerDraftSnapshot, setComposerDraftText } from "./use-composer-drafts"; +import { + flushComposerDrafts, + getComposerDraftSnapshot, + setComposerDraftText, + waitForComposerDraftsLoaded, +} from "./use-composer-drafts"; + +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + create() {} + write() {} + moveSync() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); const image = (id: string): ChatAttachment => ({ type: "image", @@ -94,6 +112,64 @@ describe("resolveQueuedEditPayload", () => { }); describe("beginQueuedRunEdit", () => { + it("silently drops an untouched edit when its run starts", () => { + const threadKey = "env-exit:thread-untouched"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe(""); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBeUndefined(); + }); + + it("recovers text after a server attachment was removed", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-removed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([image("removed")]), runs); + removeQueuedRunEditAttachment(threadKey, "removed"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("original"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + + it("recovers changed text into an empty thread draft", async () => { + await waitForComposerDraftsLoaded(); + const threadKey = "env-exit:thread-changed-recover"; + const runId = RunId.make("run-1"); + const runs = Atom.make | null>([ + { id: runId, status: "queued" }, + ]); + beginQueuedRunEdit(threadKey, edit([]), runs); + setComposerDraftText(queuedEditDraftKey(threadKey, runId), "revised"); + + appAtomRegistry.set(runs, [{ id: runId, status: "running" }]); + await flushComposerDrafts(); + + expect(getQueuedRunEdit(threadKey)).toBeNull(); + expect(getComposerDraftSnapshot(threadKey).text).toBe("revised"); + expect(getComposerDraftSnapshot(queuedEditDraftKey(threadKey, runId)).text).toBe(""); + expect(appAtomRegistry.get(threadComposerErrorsAtom)[threadKey]?.message).toBe( + "That message already started. Your edit is back in the composer.", + ); + }); + it("ends a changed edit when its run starts, without the thread's composer mounted", () => { const threadKey = "env-exit:thread-exit"; const runId = RunId.make("run-1"); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 16f2de8f099b..6c5da0db2099 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -287,3 +287,8 @@ export function resolveThreadOutboxFailureAction(input: { } return "restore"; } + +export type AcknowledgedThreadMessage = QueuedThreadMessage & { + /** The server sequence that committed the message; older servers do not report it. */ + readonly sequence?: number; +}; diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index acfdd803d425..0b529e994780 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -113,6 +113,7 @@ vi.mock("./threads", async () => { environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null), checkpointSummariesAtom: () => Atom.make([]), + sequenceAtom: () => Atom.make(0), pendingRequestsAtom: () => Atom.make(state.pendingRequests), }, threadEnvironment: { diff --git a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts index a0a7c4ffb15f..cacab28afca8 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts @@ -100,7 +100,7 @@ vi.mock("./threads", async () => { familyAtom: () => Atom.make(AsyncResult.success({})), refresh: vi.fn(), }, - environmentThreadDetails: { threadAtom: () => Atom.make(null) }, + environmentThreadDetails: { stateAtom: () => Atom.make(null) }, }; }); // The fork's cloud chat delivery: no queued message waits on a cloud machine here. @@ -229,6 +229,7 @@ beforeEach(() => { ]) { command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); } + state.start.mockResolvedValue(AsyncResult.success({})); state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ status: "ready", attachments: [], @@ -432,7 +433,8 @@ describe("queued task operation access", () => { ); await state.manager.enqueue(queued); const started = Promise.withResolvers(); - const delivery = Promise.withResolvers>(); + const delivery = + Promise.withResolvers>(); state.start.mockImplementationOnce(() => { started.resolve(); return delivery.promise; @@ -442,7 +444,7 @@ describe("queued task operation access", () => { setOperateGrant("secondary", false); delivery.resolve( accepted - ? AsyncResult.success(undefined) + ? AsyncResult.success({}) : AsyncResult.failure(Cause.fail(new Error("This connection cannot control this task."))), ); await drained; diff --git a/apps/mobile/src/state/threadOutboxDelivery.test.ts b/apps/mobile/src/state/threadOutboxDelivery.test.ts index a96108ed54b7..e7a5c5741759 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.test.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.test.ts @@ -13,9 +13,12 @@ import type { PreparedTurnAttachments } from "../lib/attachmentUpload"; const harness = vi.hoisted(() => ({ canOperate: true, - threadProjection: null as unknown as import("effect/reactivity").Atom.Writable<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>, + threadState: null as unknown as import("effect/reactivity").Atom.Writable<{ + readonly data: import("effect/Option").Option<{ + readonly messages: ReadonlyArray<{ readonly id: string }>; + }>; + readonly sequence: number; + }>, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -92,12 +95,14 @@ vi.mock("./git", () => ({ gitEnvironment: {} })); vi.mock("./threads", async () => { const { Atom } = await import("effect/reactivity"); - harness.threadProjection = Atom.make<{ - readonly projection: { readonly messages: ReadonlyArray<{ readonly id: string }> }; - } | null>(null).pipe(Atom.keepAlive); + const Option = await import("effect/Option"); + harness.threadState = Atom.make({ + data: Option.none<{ readonly messages: ReadonlyArray<{ readonly id: string }> }>(), + sequence: 0, + }).pipe(Atom.keepAlive); return { threadEnvironment: {}, - environmentThreadDetails: { threadAtom: () => harness.threadProjection }, + environmentThreadDetails: { stateAtom: () => harness.threadState }, }; }); @@ -143,6 +148,7 @@ vi.mock("./thread-outbox", async () => { }; }); +import * as Option from "effect/Option"; import { appAtomRegistry } from "./atom-registry"; import { pendingThreadCreationOutcomesAtom } from "./pending-thread-creation"; import { clearThreadComposerError, threadComposerErrorsAtom } from "./thread-composer-error"; @@ -209,7 +215,7 @@ function remainingMessages(): ReadonlyArray { beforeEach(() => { appAtomRegistry.set(acknowledgedThreadMessagesAtom, []); - appAtomRegistry.set(harness.threadProjection, null); + appAtomRegistry.set(harness.threadState, { data: Option.none(), sequence: 0 }); harness.canOperate = true; harness.draftFile.setDocument({ schemaVersion: 1, drafts: {} }); }); @@ -335,26 +341,32 @@ describe("thread outbox attachment preparation", () => { }); describe("thread outbox drain delivery cleanup", () => { - it("drops echoed acknowledged rows at the next delivery without subscribing to the thread", async () => { + it("drops caught-up acknowledged rows at the next delivery without subscribing to the thread", async () => { const first = queuedMessage({ messageId: "awaiting-echo", text: "Delivered" }); const second = queuedMessage({ messageId: "next-delivery", text: "Also delivered" }); await harness.manager.enqueue(first); await harness.manager.enqueue(second); await expect( - completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId)), + completeQueuedMessageDelivery(first, harness.manager.revisionOf(first.messageId), 7), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); - - appAtomRegistry.set(harness.threadProjection, { - projection: { messages: [{ id: "unrelated" }, { id: first.messageId }] }, + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...first, sequence: 7 }, + ]); + + // The thread applied the message, but its bounded window does not hold it. + appAtomRegistry.set(harness.threadState, { + data: Option.some({ messages: [{ id: "unrelated" }] }), + sequence: 7, }); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([first]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toHaveLength(1); await expect( - completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId)), + completeQueuedMessageDelivery(second, harness.manager.revisionOf(second.messageId), 9), ).resolves.toBe("removed"); - expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([second]); + expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([ + { ...second, sequence: 9 }, + ]); }); it("removes an acknowledged outbox item even when the sign-out archive write fails", async () => { @@ -364,7 +376,11 @@ describe("thread outbox drain delivery cleanup", () => { harness.draftFile.setWriteError(new Error("Draft storage unavailable")); await expect( - completeQueuedMessageDelivery(message, harness.manager.revisionOf(message.messageId)), + completeQueuedMessageDelivery( + message, + harness.manager.revisionOf(message.messageId), + undefined, + ), ).resolves.toBe("removed"); expect(remainingMessages()).toEqual([]); @@ -395,9 +411,9 @@ describe("thread outbox drain delivery cleanup", () => { ).toEqual([message]); if (cleared) await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe( - cleared ? "edited" : "removed", - ); + await expect( + completeQueuedMessageDelivery(message, deliveryRevision, undefined), + ).resolves.toBe(cleared ? "edited" : "removed"); appAtomRegistry.set(composerDrafts.composerCloudDraftsAtom, { accountId: null, @@ -417,7 +433,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.update(edited); await composerDrafts.archiveCloudComposerDrafts("account-a", new Set([message.environmentId])); await harness.manager.clearEnvironment(message.environmentId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); await composerDrafts.restoreCloudComposerDrafts("account-a"); expect(remainingMessages()).toEqual([edited]); }); @@ -452,7 +470,9 @@ describe("thread outbox drain delivery cleanup", () => { const edited = { ...message, text: "edited while the turn delivered" }; await harness.manager.update(edited); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("edited"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "edited", + ); expect(remainingMessages()).toEqual([edited]); expect(harness.removePersistedFile).not.toHaveBeenCalled(); @@ -463,7 +483,9 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - await expect(completeQueuedMessageDelivery(message, deliveryRevision)).resolves.toBe("removed"); + await expect(completeQueuedMessageDelivery(message, deliveryRevision, undefined)).resolves.toBe( + "removed", + ); expect(remainingMessages()).toEqual([]); expect(appAtomRegistry.get(acknowledgedThreadMessagesAtom)).toEqual([message]); @@ -484,7 +506,7 @@ describe("thread outbox drain delivery cleanup", () => { await harness.manager.enqueue(message); const deliveryRevision = harness.manager.revisionOf(message.messageId); - const cleanup = completeQueuedMessageDelivery(message, deliveryRevision); + const cleanup = completeQueuedMessageDelivery(message, deliveryRevision, undefined); await removeStarted.promise; appAtomRegistry.set(editingQueuedMessageIdsAtom, { [message.messageId]: true }); removeBarrier.resolve(); diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index de323e3b416f..d6a69ce70ebb 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -251,6 +251,7 @@ function isQueuedMessagePayloadCurrent( export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, + sequence: number | undefined, ): Promise<"removed" | "edited" | "failed"> { clearThreadComposerError( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), @@ -267,7 +268,9 @@ export async function completeQueuedMessageDelivery( if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return "edited"; } - retainAcknowledgedThreadMessage(queuedMessage); + retainAcknowledgedThreadMessage( + sequence === undefined ? queuedMessage : { ...queuedMessage, sequence }, + ); const removed = await removeThreadOutboxMessage( queuedMessage, deliveryRevision, @@ -617,6 +620,12 @@ const settledCreationOutcomeKeysAtom = Atom.make((get): ReadonlyArray => ); }).pipe(Atom.withLabel("mobile:thread-outbox:settled-creation-outcomes")); +function deliveredSequence( + result: AtomCommandResult<{ readonly sequence?: number }, unknown>, +): number | undefined { + return AsyncResult.isSuccess(result) ? result.value.sequence : undefined; +} + const runDeliveryCommand = ( command: AtomCommand, input: W, @@ -897,7 +906,11 @@ export function createThreadOutboxDelivery(): { } acknowledgedExistingThreadMessageIds.add(persistedMessage.messageId); const delivered = - (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; + (await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + )) === "removed"; if (delivered) { acknowledgedExistingThreadMessageIds.delete(persistedMessage.messageId); } @@ -1054,7 +1067,11 @@ export function createThreadOutboxDelivery(): { } recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); claimFirstTurnCloudBox(queuedMessage); - const outcome = await completeQueuedMessageDelivery(persistedMessage, deliveryRevision); + const outcome = await completeQueuedMessageDelivery( + persistedMessage, + deliveryRevision, + deliveredSequence(deliveryResult), + ); if (outcome === "edited") { if (appAtomRegistry.get(editingQueuedMessageIdsAtom)[queuedMessage.messageId]) { return true; diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index f86971960175..d4bbc6bfe5bc 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -42,7 +42,7 @@ import { setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, -} from "../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; import { appAtomRegistry } from "./atom-registry"; import { useSelectedThreadPendingRequests } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 45f06e198f47..4c14fb56254c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -110,6 +110,7 @@ const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); +const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -227,6 +228,11 @@ export function useThreadComposerState() { const selectedThreadMessages = selectedThreadProjection?.projection.messages; const selectedThreadAttempts = selectedThreadProjection?.projection.attempts; const selectedThreadNodes = selectedThreadProjection?.projection.nodes; + const selectedThreadSequence = useAtomValue( + selectedThreadDetailRef === null + ? EMPTY_THREAD_SEQUENCE_ATOM + : environmentThreadDetails.sequenceAtom(selectedThreadDetailRef), + ); const checkpointSummaries = useAtomValue( selectedThreadDetailRef === null ? EMPTY_CHECKPOINT_SUMMARIES_ATOM @@ -254,6 +260,7 @@ export function useThreadComposerState() { const pendingAcknowledgments = selectPendingAcknowledgedMessages(acknowledgedMessages, { threadKey: selectedThreadKey, queuedMessages: selectedThreadQueuedMessages, + sequence: selectedThreadSequence, projectedMessages: selectedThreadMessages, }); if (pendingAcknowledgments.length === 0) return feed; diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6a92a025778d..9ddb84efbc83 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/threads/git/GitBranchesSheet.occupancy.test.tsx", "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 22db0e643db9..68a622c541fe 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -117,6 +117,7 @@ const layerManagement = Layer.unwrap( .pipe(Effect.orDie), getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), + listActiveShellIndex: () => projections.listActiveShellIndex().pipe(Effect.orDie), listActiveSubagentThreadIds: () => projections.listActiveSubagentThreadIds().pipe(Effect.orDie), readShellSnapshot: (options) => diff --git a/apps/server/package.json b/apps/server/package.json index 0b5cd43c574e..a21a72fc63ad 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.3", + "version": "0.1.5", "license": "MIT", "repository": { "type": "git", @@ -63,6 +63,7 @@ "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "character-entities": "2.0.2", + "diff": "8.0.3", "e2b": "2.49.0", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 3d11bc8e7083..a10f9855cbbb 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -38,6 +38,7 @@ const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; +const emitTrackedToolCallKindThenHang = process.env.T3_ACP_EMIT_TRACKED_TOOL_CALL_KIND_THEN_HANG; const clientFsProbePath = process.env.T3_ACP_CLIENT_FS_PROBE_PATH; const clientFsProbeLogPath = process.env.T3_ACP_CLIENT_FS_PROBE_LOG_PATH; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -1265,6 +1266,21 @@ const program = Effect.gen(function* () { return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); } + if (emitTrackedToolCallKindThenHang !== undefined) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tracked-tool-call", + title: "git status", + kind: emitTrackedToolCallKindThenHang as AcpSchema.ToolKind, + status: "pending", + rawInput: { command: "git status" }, + }, + }); + return yield* Effect.never; + } + if (hangPromptForever || (emitEmptySuccessfulBashThenHang && promptCount === 2)) { return yield* Effect.never; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 39a6e651b84d..11cb490901a5 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1199,6 +1199,33 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.skipIf(!symlinksSupported)( + "reports image attachment dimensions when the attachments directory is a symlink", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachments-link-" }); + const realAttachmentsDir = path.join(root, "real"); + const linkedAttachmentsDir = path.join(root, "linked"); + yield* fs.makeDirectory(realAttachmentsDir); + yield* fs.symlink(realAttachmentsDir, linkedAttachmentsDir); + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000041"; + yield* fs.writeFile(path.join(realAttachmentsDir, `${attachmentId}.png`), screenshotPng); + + const result = yield* issueAssetUrl({ + resource: { _tag: "attachment", attachmentId }, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + attachmentsDir: linkedAttachmentsDir, + }), + ); + expect(result.imageDimensions).toEqual({ width: 390, height: 844 }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves document attachments inline when a viewer requests it", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 0ca85e6ea146..8eb789462723 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -659,7 +659,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ? inlinePreviewMimeTypeForExtension(extension) : undefined; if (!isGenericFile) { - imageDimensions = yield* readImageDimensionsFromHeader(attachmentPath); + imageDimensions = yield* fileSystem.realPath(attachmentPath).pipe( + Effect.flatMap(readImageDimensionsFromHeader), + Effect.orElseSucceed(() => null), + ); } claims = { version: 1, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 8558a6664c8a..bd5d3b569958 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -377,6 +377,35 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { }).pipe(Effect.provide(layerEnvironmentAuth())), ); + it.effect( + "preserves a rejected MCP pairing code for exactly one concurrent lower-access retry", + () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairing = yield* serverAuth.issuePairingCredential({ + scopes: ["orchestration:read"], + }); + + const rejected = yield* Effect.flip( + serverAuth.consumeMcpApprovalCode(pairing.credential, "full-access"), + ); + expect(rejected).toMatchObject({ + _tag: "ServerAuthMcpApprovalCodeError", + reason: "insufficient_scope", + }); + + const retry = Effect.result( + serverAuth.consumeMcpApprovalCode(pairing.credential, "read-only"), + ); + const results = yield* Effect.all([retry, retry], { concurrency: "unbounded" }); + + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + expect(results.filter((result) => result._tag === "Failure")).toMatchObject([ + { failure: { _tag: "ServerAuthMcpApprovalCodeError", reason: "unknown_or_used" } }, + ]); + }).pipe(Effect.provide(layerEnvironmentAuth())), + ); + it.effect("narrows seeded desktop grants to the requested scopes", () => Effect.gen(function* () { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 3fbe5a0061c0..7d73e6592522 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -429,7 +429,7 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError; + readonly requiredScopes?: ReadonlyArray; }, ): Effect.Effect< ResolvedBootstrapGrant, @@ -920,6 +921,12 @@ export const make = Effect.gen(function* () { } return sessions.verify(credential).pipe( mapSessionVerificationErrors, + Effect.filterOrFail( + (session) => + input?.requiredScopes === undefined || + input.requiredScopes.every((scope) => session.scopes.includes(scope)), + () => new ServerAuthScopeNotGrantedError({}), + ), Effect.map( (session) => ({ @@ -1340,7 +1347,7 @@ export const make = Effect.gen(function* () { code, access, ) => - resolveBootstrapGrant(code.trim()).pipe( + resolveBootstrapGrant(code.trim(), { requiredScopes: mcpClientScopes(access) }).pipe( Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), @@ -1350,9 +1357,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((grant) => grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) - : mcpClientScopes(access).every((scope) => grant.scopes.includes(scope)) - ? Effect.void - : Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), + : Effect.void, ), Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), ); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 3dc35aca981b..29588040217a 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -457,11 +457,10 @@ it.live( }); expect(operatorFull.status).toBe(400); - const readOnlyCode = yield* auth.issuePairingCredential({ scopes: ["orchestration:read"] }); const readOnlyApproval = yield* decide(handler, params, { _tag: "pairing-code", access: "read-only", - code: readOnlyCode.credential, + code: readOnly.credential, }); expect(readOnlyApproval.status).toBe(200); const readOnlyToken = yield* handler( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 8f664f5bc7cb..10aa0e430d74 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -573,6 +573,7 @@ export const layerMcpClientAuthenticator = Layer.effect( label: client.label, access: client.access, linked: client.peerLink, + ...(client.peerLink ? {} : { webOrigin: requestUrls(request).issuer }), }, capabilities: new Set([ "orchestration", diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 561633d3524b..4e940cd36cc5 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,6 +1,7 @@ import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -45,11 +46,11 @@ const layerPairingGrantStore = ( ); it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { - it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + it.effect("accepts lowercase one-time tokens exactly once", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); - const first = yield* bootstrapCredentials.consume(issued.credential); + const first = yield* bootstrapCredentials.consume(issued.credential.toLowerCase()); const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); @@ -59,7 +60,16 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(issued.label).toBe("Julius iPhone"); expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); - }).pipe(Effect.provide(layerPairingGrantStore())), + }).pipe( + Effect.provide(layerPairingGrantStore()), + Effect.provideServiceEffect( + Crypto.Crypto, + Effect.map(Crypto.Crypto, (crypto) => ({ + ...crypto, + randomBytes: (size) => Effect.succeed(new Uint8Array(size).fill(8)), + })), + ), + ), ); it.effect("atomically consumes a one-time token when multiple requests race", () => @@ -124,6 +134,23 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(layerPairingGrantStore())), ); + it.effect("matches a seeded desktop bootstrap credential exactly", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const exact = yield* bootstrapCredentials.consume("abcdefghjkmn"); + const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN")); + + expect(exact.method).toBe("desktop-bootstrap"); + expect(otherCase._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe( + Effect.provide( + layerPairingGrantStore({ + desktopBootstrapToken: "abcdefghjkmn", + }), + ), + ), + ); + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 4999fef9c49b..0709f371684f 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -226,6 +226,8 @@ export class PairingGrantStore extends Context.Service< input?: { readonly proofKeyThumbprint?: string; readonly requestedScopes?: ReadonlyArray; + /** All scopes must be granted; requestedScopes only requires an overlap for OAuth. */ + readonly requiredScopes?: ReadonlyArray; }, ) => Effect.Effect; } @@ -254,6 +256,11 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; +const PAIRING_TOKEN_PATTERN = new RegExp( + `^[${PAIRING_TOKEN_ALPHABET}]{${PAIRING_TOKEN_LENGTH}}$`, + "i", +); + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; @@ -481,8 +488,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => grant.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => grant.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => grant.scopes.includes(scope))) ) { return [ { @@ -532,14 +541,20 @@ const make = Effect.gen(function* () { return yield* seededResult.error; } + // Only credentials in the issued pairing-code format are case-insensitive. + const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) + ? credential.toUpperCase() + : credential; + // Check scopes in the UPDATE so rejected requests cannot spend one-time links. const consumed = yield* pairingLinks .consumeAvailable({ - credential, + credential: pairingCredential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } : {}), + ...(input?.requiredScopes !== undefined ? { requiredScopes: input.requiredScopes } : {}), consumedAt: now, now, }) @@ -560,7 +575,7 @@ const make = Effect.gen(function* () { } const matching = yield* pairingLinks - .getByCredential({ credential }) + .getByCredential({ credential: pairingCredential }) .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({}); @@ -586,8 +601,10 @@ const make = Effect.gen(function* () { } if ( - input?.requestedScopes !== undefined && - !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope)) + (input?.requestedScopes !== undefined && + !input.requestedScopes.some((scope) => matching.value.scopes.includes(scope))) || + (input?.requiredScopes !== undefined && + !input.requiredScopes.every((scope) => matching.value.scopes.includes(scope))) ) { return yield* new BootstrapCredentialScopeNotGrantedError({}); } diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 0a9f97cc3914..24c22280175c 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -6,6 +6,7 @@ import { AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, ProviderInstanceId, + SecretProviderId, ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -173,7 +174,7 @@ describe("settings mutation authorization", () => { }).pipe(Effect.scoped), ); - it.effect("does not let a settings grant create or remove providers", () => + it.effect("does not let a settings grant create or remove providers or secret providers", () => Effect.gen(function* () { let handled = false; const client = yield* RpcTest.makeClient(group).pipe( @@ -195,6 +196,11 @@ describe("settings mutation authorization", () => { providerInstanceMutation, }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); + expect( + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: { secretProviders: { [SecretProviderId.make("personal")]: null } }, + }).pipe(Effect.flip), + ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); expect(handled).toBe(false); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 0d6bae7fd6e4..b90c470e15db 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -204,6 +204,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceConfigure]: AuthSettingsWriteScope, [WS_METHODS.deviceTestHost]: AuthSettingsWriteScope, [WS_METHODS.deviceEasAccount]: AuthSettingsWriteScope, + [WS_METHODS.deviceSimlockIdentity]: AuthSettingsWriteScope, [WS_METHODS.deviceList]: AuthOrchestrationReadScope, [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 63336b29c900..30784ab33ceb 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -24,6 +24,35 @@ describe("deriveAuthClientMetadata", () => { }); }); + it.each([ + [ + "Edge on Android", + "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0", + "Edge", + ], + [ + "Edge on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/129.0.2792.84 Mobile/15E148 Safari/605.1.15", + "Edge", + ], + [ + "Firefox on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/131.0 Mobile/15E148 Safari/605.1.15", + "Firefox", + ], + [ + "Opera on iOS", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 OPiOS/5.0.0 Mobile/15E148 Safari/605.1.15", + "Opera", + ], + ])("labels %s by its own browser token", (_name, userAgent, browser) => { + const metadata = deriveAuthClientMetadata({ + request: { headers: { "user-agent": userAgent }, source: {} } as never, + }); + + expect(metadata.browser).toBe(browser); + }); + it("applies client-presented display identity without replacing transport metadata", () => { const metadata = deriveAuthClientMetadata({ request: { diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index dc89a5da8114..29c0c5100a19 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -128,9 +128,9 @@ function inferBrowser(userAgent: string | undefined): string | undefined { return undefined; } const normalized = userAgent.toLowerCase(); - if (/edg\//.test(normalized)) return "Edge"; - if (/opr\//.test(normalized)) return "Opera"; - if (/firefox\//.test(normalized)) return "Firefox"; + if (/edg(?:e|a|ios)?\//.test(normalized)) return "Edge"; + if (/opr\/|opios\//.test(normalized)) return "Opera"; + if (/firefox\/|fxios\//.test(normalized)) return "Firefox"; if (/electron\//.test(normalized)) return "Electron"; if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 0aeb7d6a3b5a..06b949e3013a 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -68,6 +69,15 @@ const subjectFlag = Flag.String("subject").pipe( ); const baseUrlFlag = Flag.String("base-url").pipe( + Flag.withSchema( + Schema.URLFromString.pipe( + Schema.check( + Schema.makeFilter((url) => url.protocol === "http:" || url.protocol === "https:", { + expected: "an http:// or https:// URL", + }), + ), + ), + ), Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), Flag.optional, ); @@ -99,7 +109,7 @@ const pairingCreateCommand = Command.make("create", { }); const output = formatIssuedPairingCredential(issued, { json: flags.json, - ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value.href } : {}), }); yield* Console.log(output); }), diff --git a/apps/server/src/cli/consoleStdin.test.ts b/apps/server/src/cli/consoleStdin.test.ts new file mode 100644 index 000000000000..c799d3c2a5e1 --- /dev/null +++ b/apps/server/src/cli/consoleStdin.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const io = vi.hoisted(() => ({ + platform: vi.fn(() => "win32"), + stat: vi.fn(() => ({ isCharacterDevice: (): boolean => true })), + open: vi.fn(() => 42), + close: vi.fn(), + isatty: vi.fn((fd: number) => fd !== 0), +})); + +vi.mock("node:fs", () => ({ fstatSync: io.stat, openSync: io.open, closeSync: io.close })); +vi.mock("node:tty", async () => { + const { EventEmitter } = await import("node:events"); + return { + isatty: io.isatty, + ReadStream: class extends EventEmitter { + readonly isTTY = true; + readonly _readableState = { reading: true }; + _handle: { reading: boolean; readStop: () => void } | undefined = { + reading: true, + readStop: vi.fn(), + }; + }, + }; +}); +vi.mock("@t3tools/shared/HostProcess", () => ({ Platform: { defaultValue: io.platform } })); + +const originalStdin = Object.getOwnPropertyDescriptor(process, "stdin")!; + +beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.stubEnv("T3CODE_DESKTOP_CLI_LAUNCHER", "1"); +}); + +afterEach(() => { + Object.defineProperty(process, "stdin", originalStdin); + vi.unstubAllEnvs(); +}); + +describe("attachConsoleStdin", () => { + it("reopens the console when Node replaces missing Windows stdin with NUL", async () => { + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + + expect(attachConsoleStdin()).toBe(42); + expect(process.stdin.isTTY).toBe(true); + expect(interactiveStdin()).toBe(42); + expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined(); + }); + + it("releases console reads when the picker pauses stdin before launching an agent", async () => { + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + attachConsoleStdin(); + const stdin = process.stdin as unknown as { + emit(event: string): boolean; + _readableState: { reading: boolean }; + _handle: { reading: boolean; readStop: () => void }; + }; + stdin.emit("pause"); + expect(stdin._readableState.reading).toBe(false); + expect(stdin._handle.reading).toBe(false); + expect(stdin._handle.readStop).toHaveBeenCalledOnce(); + expect(interactiveStdin()).toBe(42); + }); + + it("can pause after the reopened stream's handle has been closed", async () => { + const { attachConsoleStdin } = await import("./consoleStdin.ts"); + attachConsoleStdin(); + const stdin = process.stdin as unknown as { + emit(event: string): boolean; + _handle: undefined; + }; + stdin._handle = undefined; + expect(() => stdin.emit("pause")).not.toThrow(); + }); + + it("still reopens a closed stdin", async () => { + io.stat.mockImplementation(() => { + throw new Error("EBADF"); + }); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBe(42); + expect(interactiveStdin()).toBe(42); + }); + + it.each(["pipe", "file"])("preserves redirected %s input", async () => { + io.stat.mockReturnValue({ isCharacterDevice: () => false }); + const stdin = process.stdin; + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(process.stdin).toBe(stdin); + expect(interactiveStdin()).toBe("inherit"); + expect(io.open).not.toHaveBeenCalled(); + }); + + it.each(["console input", "redirected output", "unmarked process", "non-Windows"])( + "leaves %s alone", + async (scenario) => { + if (scenario === "console input") io.isatty.mockReturnValue(true); + if (scenario === "redirected output") io.isatty.mockReturnValue(false); + if (scenario === "unmarked process") delete process.env.T3CODE_DESKTOP_CLI_LAUNCHER; + if (scenario === "non-Windows") io.platform.mockReturnValue("darwin"); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + expect(io.open).not.toHaveBeenCalled(); + expect(process.env.T3CODE_DESKTOP_CLI_LAUNCHER).toBeUndefined(); + }, + ); + + it("keeps inherited input when CONIN$ cannot be opened", async () => { + io.open.mockImplementation(() => { + throw new Error("ENOENT"); + }); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + }); + + it("closes a reopened descriptor that is not a console", async () => { + io.isatty.mockImplementation((fd) => fd === 1); + const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); + expect(attachConsoleStdin()).toBeUndefined(); + expect(interactiveStdin()).toBe("inherit"); + expect(io.close).toHaveBeenCalledWith(42); + }); +}); diff --git a/apps/server/src/cli/consoleStdin.ts b/apps/server/src/cli/consoleStdin.ts index 91232cb31087..18ee7bed6d5a 100644 --- a/apps/server/src/cli/consoleStdin.ts +++ b/apps/server/src/cli/consoleStdin.ts @@ -5,31 +5,30 @@ import * as NodeTty from "node:tty"; import * as HostProcess from "@t3tools/shared/HostProcess"; /** - * Electron's Windows executable is a GUI program, so `t3.cmd` under - * `ELECTRON_RUN_AS_NODE` gets the console for stdout and stderr but no stdin. - * Piped or redirected input and runs whose output is not a console are left alone. + * Electron's Windows launcher has no stdin; Node substitutes a non-console NUL device. + * Preserve pipes, files, console input, and runs without console output. + * Explicit `< NUL` is indistinguishable from missing launcher stdin and also reattaches. */ function needsConsoleStdin(input: { readonly platform: NodeJS.Platform; readonly env: NodeJS.ProcessEnv; - readonly stdinOpen: boolean; + readonly stdinMissing: boolean; readonly stdoutIsConsole: boolean; }): boolean { return ( input.platform === "win32" && // Set only by the launcher (DesktopCliShim), never for the desktop backend. input.env.T3CODE_DESKTOP_CLI_LAUNCHER === "1" && - !input.stdinOpen && + input.stdinMissing && input.stdoutIsConsole ); } -const isOpen = (fd: number) => { +const isMissingInput = (fd: number) => { try { - NodeFS.fstatSync(fd); - return true; + return NodeFS.fstatSync(fd).isCharacterDevice() && !NodeTty.isatty(fd); } catch { - return false; + return true; } }; @@ -38,7 +37,7 @@ export function attachConsoleStdin(): number | undefined { const needed = needsConsoleStdin({ platform: HostProcess.Platform.defaultValue(), env: process.env, - stdinOpen: isOpen(0), + stdinMissing: isMissingInput(0), stdoutIsConsole: NodeTty.isatty(1), }); // The launcher marker must not propagate to child shells or t3 processes. @@ -55,6 +54,18 @@ export function attachConsoleStdin(): number | undefined { return undefined; } const stdin = new NodeTty.ReadStream(fd); + // Like Node's built-in stdin, release pending console reads before a child takes over. + stdin.on("pause", () => { + const internal = stdin as unknown as { + _handle?: { reading: boolean; readStop(): void }; + _readableState: { reading: boolean }; + }; + const handle = internal._handle; + if (!handle) return; + internal._readableState.reading = false; + handle.reading = false; + handle.readStop(); + }); Object.defineProperty(process, "stdin", { value: stdin, configurable: true, enumerable: true }); consoleStdinFd = fd; return fd; diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts index 5ad9bde0acc2..88fc9cec74c1 100644 --- a/apps/server/src/cli/pair.test.ts +++ b/apps/server/src/cli/pair.test.ts @@ -488,3 +488,63 @@ describe("auth scope options", () => { }), ); }); + +describe("auth pairing create --base-url", () => { + it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])( + "rejects %s before issuing a token", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const error = yield* runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + ]).pipe(Effect.provide(layerCliRuntime), Effect.flip); + + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + const flagError = error.errors[0]; + if (flagError?._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(flagError?._tag)}`); + } + assert.equal(flagError.option, "base-url"); + assert.equal(flagError.value, baseUrl); + + const listOutput = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + assert.deepEqual(JSON.parse(listOutput), []); + }), + ); + + it.effect.each(["http://192.168.1.5:3773", "https://example.com", "http:localhost:3773"])( + "prints a pair URL for %s", + (baseUrl) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-")); + const output = yield* captureStdout( + runCli([ + "auth", + "pairing", + "create", + "--base-dir", + baseDir, + "--base-url", + baseUrl, + "--json", + ]), + ); + const issued = JSON.parse(output) as { + readonly credential: string; + readonly pairUrl: string; + }; + + assert.equal(issued.pairUrl, `${new URL(baseUrl).origin}/pair#token=${issued.credential}`); + }), + ); +}); diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 92c30422153b..394727fcf26f 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -18,11 +18,14 @@ import { import * as NetService from "@t3tools/shared/Net"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; +import { FetchHttpClient } from "effect/http"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; @@ -37,6 +40,10 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -122,6 +129,66 @@ it.effect("adds, renames, and removes projects through the V2 project CLI domain }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect.each(["snapshot", "mutation"] as const)( + "waits for a busy live server's %s response", + (slowRequest) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-live-project-cli-" }); + const baseDir = NodePath.join(root, "state"); + const workspaceRoot = NodePath.join(root, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* persistServerRuntimeState({ + path: NodePath.join(baseDir, "userdata", "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 3_773, + }), + }); + const now = "2026-01-01T00:00:00.000Z"; + const requestReceived = Promise.withResolvers(); + const releaseResponse = Promise.withResolvers(); + let snapshotRequests = 0; + let mutations = 0; + const fetch: typeof globalThis.fetch = async (_input, init) => { + const isMutation = init?.method === "POST"; + if (isMutation) mutations++; + else snapshotRequests++; + if (slowRequest === "mutation" ? isMutation : !isMutation && snapshotRequests === 2) { + requestReceived.resolve(); + await releaseResponse.promise; + } + return Response.json( + isMutation + ? { + id: "live-project", + title: NodePath.basename(workspaceRoot), + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + } + : { projects: [], updatedAt: now }, + ); + }; + + const add = yield* runCli(["project", "add", workspaceRoot, "--base-dir", baseDir]).pipe( + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.forkChild, + ); + yield* Effect.promise(() => requestReceived.promise); + yield* TestClock.adjust("2 seconds"); + releaseResponse.resolve(); + yield* Fiber.join(add); + + assert.equal(snapshotRequests, 2); + assert.equal(mutations, 1); + assert.deepEqual((yield* readProjects(baseDir)).projects, []); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-v2-project-lookup-" }); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index a5c8aa532ff3..3d02f9c921fc 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -214,7 +214,8 @@ const layerProjectCliRuntime = RuntimeLayer.layerProjectService.pipe( Layer.provideMerge(SqlitePersistence.layerConfig), ); -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT = Duration.seconds(1); +const PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); const withProjectCliSessionToken = ( environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"], run: (token: string) => Effect.Effect, @@ -228,9 +229,6 @@ const withProjectCliSessionToken = ( (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), ); -const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); - const makeLiveServerClient = (origin: string) => HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin, @@ -317,17 +315,14 @@ const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* ( }); const fetchLiveOrchestrationSnapshot = Effect.fn("project.fetchLiveOrchestrationSnapshot")( - function* (origin: string, bearerToken: string) { + function* (origin: string, bearerToken: string, _timeout?: Duration.Duration) { const client = yield* makeLiveServerClient(origin); return yield* client.projects.snapshot({ headers: { authorization: `Bearer ${bearerToken}` }, }); }, - (effect, _origin, _bearerToken) => - effect.pipe( - withProjectCliLiveServerTimeout, - Effect.mapError(projectCommandErrorFromLiveServerRequest), - ), + (effect, _origin, _bearerToken, timeout = PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT) => + effect.pipe(Effect.timeout(timeout), Effect.mapError(projectCommandErrorFromLiveServerRequest)), ); const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestrationCommand")( @@ -340,7 +335,7 @@ const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestr }, (effect, _origin, _bearerToken, _command) => effect.pipe( - withProjectCliLiveServerTimeout, + Effect.timeout(PROJECT_CLI_LIVE_SERVER_REQUEST_TIMEOUT), Effect.mapError(projectCommandErrorFromLiveServerRequest), ), ); @@ -361,7 +356,11 @@ const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecu } const attempt = withProjectCliSessionToken(environmentAuth, (token) => - fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + fetchLiveOrchestrationSnapshot( + runtimeState.value.origin, + token, + PROJECT_CLI_LIVE_SERVER_PROBE_TIMEOUT, + ).pipe( Effect.as({ origin: runtimeState.value.origin, }), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 84877d41a70c..1e3157adfff5 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -313,3 +313,36 @@ it.layer(NodeServices.layer)("pruning runtimes on start", (it) => { }), ); }); + +it("a host crosses tracks only to a release that knows its whole database", () => { + const cross = ( + serverVersion: string, + targetVersion: string, + targetDatabaseMigration: number | undefined, + databaseMigration = 72, + ) => + ServerSelfUpdate.trackSwitchBlockReason({ + serverVersion, + targetVersion, + targetDatabaseMigration, + databaseMigration: () => databaseMigration, + }); + const nightly = "0.1.4-nightly.20261027.3796"; + + expect(cross(nightly, "0.1.4", 72)).toBeNull(); + // Another client moved this host to a newer Nightly after the switch was planned. + expect(cross(nightly, "0.1.4", 72, 73)).toContain("does not support yet"); + expect(cross(nightly, "0.1.4", undefined)).toContain("Cannot confirm"); + // A Stable tag can carry a migration the latest Nightly lacks. + expect(cross("0.1.4", nightly, 72, 73)).toContain("does not support yet"); + expect(cross("0.1.3", "0.1.4", undefined)).toBeNull(); + // A database that exists but cannot be read proves nothing. + expect( + ServerSelfUpdate.trackSwitchBlockReason({ + serverVersion: nightly, + targetVersion: "0.1.4", + targetDatabaseMigration: 72, + databaseMigration: () => undefined, + }), + ).toContain("Cannot confirm"); +}); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index ab6a69fc50a8..d72c5e9218df 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -20,11 +20,12 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; -import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; +import { CLI_RELEASE_BASE_URL_ENV, crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; +import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -93,6 +94,26 @@ export const pruneRuntimesOnStart = Effect.fn("cloud.server_self_update.prune_on }, ); +/** The client checks this when planning; the host checks again because its database may have + * moved on since (another client updated it). Stable and Nightly each build their own commits, + * so crossing tracks needs the target release to know every migration this database holds. */ +export function trackSwitchBlockReason(input: { + readonly serverVersion: string; + readonly targetVersion: string; + readonly targetDatabaseMigration: number | undefined; + /** Read only for a crossing; undefined when the database cannot be read. */ + readonly databaseMigration: () => number | undefined; +}): string | null { + if (!crossesReleaseTrack(input.serverVersion, input.targetVersion)) return null; + const databaseMigration = input.databaseMigration(); + if (input.targetDatabaseMigration === undefined || databaseMigration === undefined) { + return `Cannot confirm this database works with t3@${input.targetVersion}. Switch tracks from Settings โ†’ About โ†’ Update track, which checks it first.`; + } + return input.targetDatabaseMigration < databaseMigration + ? `This database has changes t3@${input.targetVersion} does not support yet; it stays on this release.` + : null; +} + export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; @@ -119,12 +140,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ? new ServerSelfUpdateError({ reason }) : new ServerSelfUpdateError({ reason, cause }); + const crossesTrackUnsafely = (input: ServerSelfUpdateInput) => + trackSwitchBlockReason({ + serverVersion: packageJson.version, + targetVersion: input.targetVersion, + targetDatabaseMigration: input.targetDatabaseMigration, + databaseMigration: () => databaseMigrationHead(serverConfig.dbPath), + }); + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { if (capability === "desktop-managed") { + const unsafeCrossing = input.channel === undefined ? null : crossesTrackUnsafely(input); + if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing); if (desktopAppUpdate.available) { - return yield* desktopAppUpdate.run(reportProgress); + return yield* desktopAppUpdate.run( + reportProgress, + input.channel === undefined + ? undefined + : { channel: input.channel, targetVersion: input.targetVersion }, + ); } return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", @@ -136,6 +172,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ); } + const unsafeCrossing = crossesTrackUnsafely(input); + if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing); const targetVersion = input.targetVersion.trim(); if (!isExactServiceVersion(targetVersion)) { return yield* failWith(`'${targetVersion}' is not an exact t3 version.`); diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index 18a5323cd590..efc37266be8b 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,4 +1,16 @@ +import * as NodeSqlite from "node:sqlite"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { + BUILD_MIGRATION_HEAD, + databaseMigrationHead, +} from "../persistence/databaseMigrationHead.ts"; +import { migrationManifest } from "../persistence/Migrations.ts"; import { runServicePreflight } from "./servicePreflight.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -20,3 +32,49 @@ it.each([1])("blocks legacy launcher protocol %i with exact recovery steps", (la expect(reason).toContain("t3 --version"); expect(reason).toContain("which -a t3"); }); + +it.layer(NodeServices.layer)("service preflight database", (it) => { + it.effect("blocks a database that a newer build migrated", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const databasePath = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-preflight-" }), + "state.sqlite", + ); + const db = new NodeSqlite.DatabaseSync(databasePath); + db.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER PRIMARY KEY, name TEXT)"); + const insert = db.prepare("INSERT INTO effect_sql_migrations VALUES (?, ?)"); + for (const [id, name] of migrationManifest) insert.run(id, name); + const preflight = () => + runServicePreflight({ databasePath, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }); + + expect(preflight().status).toBe("ready"); + expect(databaseMigrationHead(databasePath)).toBe(BUILD_MIGRATION_HEAD); + + const newerId = Math.max(...migrationManifest.map(([id]) => id)) + 1; + insert.run(newerId, "FromNightly"); + db.close(); + const result = preflight(); + expect(result.status).toBe("blocked"); + expect(result.status === "blocked" ? result.reason : "").toContain(`${newerId}_FromNightly`); + // Servers report this head, so clients see a database a newer build migrated. + expect(databaseMigrationHead(databasePath)).toBe(newerId); + + // A database that exists but cannot be read has an unknown history, so it blocks. + const unreadable = path.join(path.dirname(databasePath), "unreadable.sqlite"); + yield* fs.writeFileString(unreadable, "not a database"); + expect(databaseMigrationHead(unreadable)).toBeUndefined(); + expect( + runServicePreflight({ + databasePath: unreadable, + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + }).status, + ).toBe("blocked"); + // A missing database is a fresh install. + expect(databaseMigrationHead(path.join(path.dirname(databasePath), "missing.sqlite"))).toBe( + BUILD_MIGRATION_HEAD, + ); + }), + ); +}); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 8865ce7b3d11..9479f3f4a819 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,4 +1,5 @@ import packageJson from "../../package.json" with { type: "json" }; +import { BUILD_MIGRATION_HEAD, recordedMigrations } from "../persistence/databaseMigrationHead.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -34,9 +35,34 @@ export function runServicePreflight(input: { }; } + const newer = newerRecordedMigrations(input.databasePath); + if (newer === null) { + return { + status: "blocked", + version, + reason: `Could not read this database to confirm t3@${version} supports it.`, + }; + } + if (newer.length > 0) { + return { + status: "blocked", + version, + reason: `This database has migrations that t3@${version} does not know: ${newer.join(", ")}. A newer build wrote them, and running t3@${version} on this database is not safe. Stay on the newer release, or wait for a release that includes these migrations.`, + }; + } + return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; } +/** Migrations a newer build recorded, or null when the database exists but cannot be read. + * Startup already handles renamed ids this build knows. */ +function newerRecordedMigrations(databasePath: string): ReadonlyArray | null { + const recorded = recordedMigrations(databasePath); + return recorded === null + ? null + : recorded.filter(({ id }) => id > BUILD_MIGRATION_HEAD).map(({ id, name }) => `${id}_${name}`); +} + export function decodeServicePreflightResult(value: unknown): ServicePreflightResult | undefined { if (typeof value !== "object" || value === null) { return undefined; diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts index 86d445d1a52c..f90bef39fcf0 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts @@ -132,6 +132,24 @@ it.layer(NodeServices.layer)("desktop app update", (it) => { }), ); + it.effect("succeeds without an install when a track switch finds the host current", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "up-to-date", currentVersion: "1.2.3" }), { + outcome: "up-to-date", + }), + ], + }); + const trackSwitch = { channel: "latest" as const, targetVersion: "1.2.3" }; + + expect(yield* service.run(() => Effect.void, trackSwitch)).toEqual({ + targetVersion: "1.2.3", + method: "desktop-app", + }); + }), + ); + it.effect("replays a retained commit failure for the preparation token", () => Effect.gen(function* () { const { service } = yield* makeHarness({ diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index d02c3108777d..e901b04cfb5d 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -44,6 +44,7 @@ export class DesktopAppUpdate extends Context.Service< reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch?: DesktopTelemetryReceiver.DesktopTrackSwitch, ) => Effect.Effect; /** Success stops the server; returns only on installation failure or timeout. */ readonly commit: ( @@ -71,6 +72,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch: boolean, ) { const lastStage = yield* Ref.make(null); const emitStage = ( @@ -122,6 +124,11 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; } if (report.outcome === "up-to-date") { + // A switch's host already on the newest build of its track only saved the track. + if (trackSwitch) { + yield* Ref.set(inFlight, false); + return { targetVersion: report.state.currentVersion, method: "desktop-app" as const }; + } return yield* failWith( `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, ); @@ -132,7 +139,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( }); const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( - function* (reportProgress) { + function* (reportProgress, trackSwitch) { if (!available) { return yield* failWith( "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", @@ -154,13 +161,18 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( // Subscribe before requesting to catch fast reports. const { changes } = yield* receiver.desktopUpdates; yield* receiver - .requestDesktopUpdate(requestId) + .requestDesktopUpdate(requestId, trackSwitch) .pipe( Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app on this machine.", error), ), ); - return yield* consumeReports(requestId, changes, reportProgress).pipe( + return yield* consumeReports( + requestId, + changes, + reportProgress, + trackSwitch !== undefined, + ).pipe( Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)), ); }), diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts index de5ff7ec713d..ac66a52f6d2a 100644 --- a/apps/server/src/device/AgentDeviceTarget.ts +++ b/apps/server/src/device/AgentDeviceTarget.ts @@ -9,7 +9,11 @@ import type { AgentDaemonEndpoint } from "./DeviceHost.ts"; const encodeEndpoint = Schema.encodeEffect( Schema.fromJsonString( - Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }), + Schema.Struct({ + daemonBaseUrl: Schema.String, + daemonAuthToken: Schema.String, + stateDir: Schema.optionalKey(Schema.String), + }), ), ); @@ -40,7 +44,7 @@ export const agentDeviceSession = (threadId: string, hostId: string, deviceId: s export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* ( file: string, - endpoint: AgentDaemonEndpoint, + endpoint: AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -48,6 +52,7 @@ export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig") const content = yield* encodeEndpoint({ daemonBaseUrl: endpoint.baseUrl, daemonAuthToken: endpoint.token, + ...(endpoint.stateDir === undefined ? {} : { stateDir: endpoint.stateDir }), }); if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return; const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" }); diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 41e1de430469..90f46a4b66d3 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -1,5 +1,9 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -43,6 +47,40 @@ const makeReady = ( const udid = "SIM-1"; describe("runDeviceAction", () => { + it.live.each([ + "https://example.com/callback?code=1&state=2", + "https://example.com/it's?a=1&b=2", + "https://example.com/a b?key=value", + "https://example.com/plain", + ])("opens the complete Android URL %s", (url) => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "android", { + type: "openUrl", + deviceId: "emulator-5554", + url, + }); + const args = calls[0]!.args; + expect(args.slice(0, 3)).toEqual(["-s", "emulator-5554", "shell"]); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + // adb joins arguments verbatim before the Android shell parses them. + const stdout = yield* spawner.string( + ChildProcess.make("/bin/sh", [ + "-c", + `am() { printf '%s\\0' "$@"; }\n${args.slice(3).join(" ")}`, + ]), + ); + expect(stdout.split("\0").slice(0, -1)).toEqual([ + "start", + "-a", + "android.intent.action.VIEW", + "-d", + url, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect.each([ ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"], ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"], @@ -60,6 +98,60 @@ describe("runDeviceAction", () => { expect(calls.at(-1)?.args.at(-1)).toBe(expected); }), ); + + it.effect.each([ + ["camera", "grant", "android.permission.CAMERA"], + ["photos", "revoke", "android.permission.READ_MEDIA_IMAGES"], + ] as const)( + "reports an Android %s %s that no pm command applied", + ([permission, decision, firstPermission]) => + Effect.gen(function* () { + const { ready } = makeReady((call) => ({ + code: 1, + stderr: `Unknown package: ${call.args.at(-1)}`, + })); + const error = yield* Effect.flip( + runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.missing", + permission, + decision, + }), + ); + expect(error._tag).toBe("DeviceOperationError"); + expect(error.operation).toBe("permission"); + expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ + stderr: `Unknown package: ${firstPermission}`, + }); + }), + ); + + it.effect.each([ + [ + "photos", + ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], + 1, + ], + ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], 0], + ] as const)( + "keeps a partially applied %s permission change", + ([permission, permissions, success]) => + Effect.gen(function* () { + const { ready, calls } = makeReady((call) => ({ + code: call.args.at(-1) === permissions[success] ? 0 : 1, + })); + const result = yield* runDeviceAction(ready, "android", { + type: "setPermission", + deviceId: "emulator-5554", + appId: "com.example.app", + permission, + decision: "grant", + }); + expect(result).toBeUndefined(); + expect(calls.map((call) => call.args.at(-1))).toEqual(permissions); + }), + ); }); describe("readDeviceDetail", () => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 002d8cb3e7d2..6b734b6b9b48 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -11,6 +11,7 @@ import { type DeviceTextSize, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { DeviceHostReady } from "./DeviceHost.ts"; @@ -342,13 +343,17 @@ const serveSimPermissions = Effect.fn("DeviceActions.serveSimPermissions")(funct const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); +// adb shell joins arguments without escaping before the device's shell parses them. +const androidShellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`; + const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( run: Runner, input: DeviceActionInput, ) { const serial = input.deviceId; const shell = (args: ReadonlyArray, operation: string) => - adb(run, serial, ["shell", ...args], operation); + adb(run, serial, ["shell", ...args.map(androidShellWord)], operation); switch (input.type) { case "setAppearance": yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance"); @@ -428,9 +433,13 @@ const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( }); } const verb = input.decision === "grant" ? "grant" : "revoke"; - for (const permission of permissions) { - yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore); - } + // `pm` rejects a permission the app or system image does not declare, and Photos lists both + // the Android 13 and the older storage permission, so fail only when none of them applied. + const results = yield* Effect.forEach(permissions, (permission) => + shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.result), + ); + const failure = results.find(Result.isFailure); + if (failure && !results.some(Result.isSuccess)) return yield* failure.failure; return; } case "openUrl": diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 13a1e55e1488..c168ba657afe 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -84,6 +84,17 @@ export interface DeviceOpenContext { export type AgentDaemonEndpoint = Pick; +export interface AgentDeviceBinding extends AgentDaemonEndpoint { + /** Null when the session alone picks the device and selector flags would be refused. */ + readonly agentDeviceId: string | null; + /** The one session the device is leased to, shared by every thread. */ + readonly session?: string; + /** agent-device state holding that session's connection. */ + readonly stateDir?: string; + /** agent-device accepts installs into the session only from the directory that opened it. */ + readonly installDirectory?: string; +} + /** Where the proxy sends a device hub request, for hosts that serve several upstreams. */ export interface HubUpstream { readonly url: string; @@ -124,10 +135,7 @@ export interface DeviceOperations { */ readonly agentEndpoint?: ( deviceId: string, - ) => Effect.Effect< - AgentDaemonEndpoint & { readonly agentDeviceId: string }, - DeviceHostUnavailableError - >; + ) => Effect.Effect; /** * Set by hosts that end devices on their own (EAS stops sessions at their * limits), so a session on a device the host stops listing is dropped. diff --git a/apps/server/src/device/DeviceHubProxy.stream.test.ts b/apps/server/src/device/DeviceHubProxy.stream.test.ts index 0251dabf7ace..72340560d4f3 100644 --- a/apps/server/src/device/DeviceHubProxy.stream.test.ts +++ b/apps/server/src/device/DeviceHubProxy.stream.test.ts @@ -1,9 +1,12 @@ import { expect, it } from "@effect/vitest"; import { NodeWS } from "@effect/platform-node/NodeSocket"; +import * as NodeNet from "node:net"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import { HttpRouter, HttpServer } from "effect/http"; +import * as Socket from "effect/socket/Socket"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { AuthOrchestrationOperateScope, @@ -87,3 +90,34 @@ it.effect("preserves binary input and admission frames through the real socket p expect(frames).toEqual([[0x83], [0x03, 1], [0x03, 2]]); }), ); + +it.live("closes an upstream socket that is still connecting without an unhandled error", () => + Effect.gen(function* () { + const dialed = Promise.withResolvers(); + // Accepts the connection and never answers the WebSocket handshake. + const hub = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer((socket) => dialed.resolve(socket)); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + const address = hub.address(); + if (typeof address !== "object" || !address) throw new Error("Missing hub port"); + // Handshake headers, as Simlock's routes send, select the `ws` package over the global WebSocket. + const upstream = yield* Socket.makeWebSocket(`ws://127.0.0.1:${address.port}`, { + headers: { authorization: "Bearer test" }, + }).pipe(Effect.provide(DeviceHubProxy.upstreamWebSocketConstructor)); + const reading = yield* Effect.forkChild( + Effect.scoped(Effect.flatMap(upstream.reader, ({ pull }) => pull)), + ); + const connection = yield* Effect.promise(() => dialed.promise); + const abandoned = new Promise((resolve) => connection.once("close", () => resolve())); + yield* Fiber.interrupt(reading); + yield* Effect.promise(() => abandoned); + }), +); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 390247e2bda0..89cad0d7206e 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -2,6 +2,7 @@ // Proxy with environment read scope for streams and operate scope for input and tuning. import { AuthOrchestrationReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { HttpClient, @@ -75,16 +76,32 @@ const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: st return headers; }; +// Effect drops its listeners before closing a socket that is still connecting, and `ws` then +// emits an `error` nobody handles, which exits the server. +export const upstreamWebSocketConstructor = Layer.effect( + Socket.WebSocketConstructor, + Effect.gen(function* () { + const make = yield* Socket.WebSocketConstructor; + return (url, options) => { + const socket = make(url, options); + socket.addEventListener("error", () => undefined); + return socket; + }; + }), +).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor)); + const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( request: HttpServerRequest.HttpServerRequest, upstreamUrl: string, protocols: ReadonlyArray = [], + headers: Readonly> = {}, ) { const client = yield* request.upgrade; const upstream = yield* Socket.makeWebSocket(upstreamUrl, { openTimeout: "10 seconds", ...(protocols.length > 0 ? { protocols: [...protocols] } : {}), - }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor)); + ...(Object.keys(headers).length > 0 ? { headers: { ...headers } } : {}), + }).pipe(Effect.provide(upstreamWebSocketConstructor)); yield* Effect.scoped( Effect.gen(function* () { const writeToClient = yield* client.writer; @@ -194,7 +211,12 @@ const handler = Effect.gen(function* () { } const upstreamUrl = routed?.url ?? `${ready.hub.origin}${hubPath}${search}`; if (upgrade) { - return yield* proxyWebSocket(request, upstreamUrl.replace(/^http/, "ws"), routed?.protocols); + return yield* proxyWebSocket( + request, + upstreamUrl.replace(/^http/, "ws"), + routed?.protocols, + routed?.headers, + ); } return yield* proxyHttp(request, upstreamUrl, routed?.headers); }); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 126109433890..a9ff957b9199 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -52,7 +52,7 @@ const fixture = Effect.fn("fixture")(function* ( | DeviceHost.AgentDeviceStartError, inspectError = false, installTool?: Parameters[3], - options: { readonly endsDevices?: boolean } = {}, + options: { readonly endsDevices?: boolean; readonly routesStreams?: boolean } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -137,6 +137,7 @@ const fixture = Effect.fn("fixture")(function* ( }, helpers: { serveSimAxSettings: null, serveSimCli: null }, run, + ...(options.routesStreams ? { upstream: () => Effect.succeed(null) } : {}), }; const host: DeviceHost.DeviceHost["Service"] = { ...(inspectError @@ -401,6 +402,29 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect("opens emulators on hosts that route their own streams without ffmpeg", () => + Effect.gen(function* () { + const { service, requests, encoderResult } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { routesStreams: true }, + ); + yield* Ref.set(encoderResult, { code: 127, stdout: "", stderr: "host commands unavailable" }); + yield* service.configure({ enabled: true }); + const session = yield* service.open({ + threadId: ThreadId.make("pool-emulator"), + deviceId: "Pixel_API_35", + platform: "android", + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); + }).pipe(Effect.scoped), + ); + it.effect("installs agent support only after the separate agent permission", () => Effect.gen(function* () { const { service, agentStarts, agentStops, settings } = yield* fixture(); @@ -787,6 +811,103 @@ it.effect.each(["shutdown", "close"] as const)( }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); +it.effect("leaves a session its host leases to every thread open when one thread closes", () => + Effect.gen(function* () { + const deviceId = DeviceId.make("33333333-3333-3333-3333-333333333333"); + const http = HttpClient.make((request) => + Effect.sync(() => + new URL(request.url).pathname === "/api/devices" + ? HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted: true, + }, + ], + }), + ) + : HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })), + ), + ); + const run = () => Effect.succeed({ code: 0, stdout: "", stderr: "" }); + const ready: DeviceHost.DeviceHostAgentReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: { + ...makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http, + run, + androidAvailable: Effect.succeed(true), + }), + agentEndpoint: () => + Effect.succeed({ + baseUrl: "http://agent.test", + token: "agent-token", + agentDeviceId: null, + session: "lease-session", + }), + }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run, + agentDevice: null, + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + label: "Pool", + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Pool", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.succeed(ready), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const released: string[] = []; + const service = yield* DeviceService.makeWithHosts( + new Map([[host.id, host]]), + undefined, + () => Effect.succeed("agent-device.json"), + undefined, + { + bind: ({ session }) => Effect.succeed(session), + prune: () => Effect.void, + release: (launcher) => Effect.sync(() => void released.push(launcher)), + }, + ).pipe(Effect.provide(NodeCrypto.layer)); + const threads = [ThreadId.make("pool-a"), ThreadId.make("pool-b")]; + for (const threadId of threads) { + yield* service.open({ threadId, deviceId, platform: "ios" }); + const target = { threadId, hostId: LOCAL_DEVICE_HOST_ID, deviceId }; + const agent = yield* service.agentTarget(target); + expect(agent.session).toBe("lease-session"); + yield* service.agentLauncher({ ...target, targetArgs: agent.args }); + } + yield* service.close({ threadId: threads[0]! }); + expect(released).toEqual([]); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + Effect.scoped, + ), +); + it.effect.each([ { hubReports: "off", outcome: "succeeds" }, { hubReports: "booted", outcome: "fails" }, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 6d7ede5e845f..b2ef41f148a1 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -20,8 +20,12 @@ import { type DeviceEasAccount, DeviceEasAccountError, type DeviceEasAccountInput, + type DeviceSimlockIdentity, + DeviceSimlockIdentityError, + type DeviceSimlockIdentityInput, type DeviceHostConfig, type EasDeviceHostConfig, + type SimlockDeviceHostConfig, type SshDeviceHostConfig, type DeviceHostSummary, LOCAL_DEVICE_HOST_ID, @@ -67,6 +71,8 @@ import * as ProcessRunner from "../processRunner.ts"; import * as DeviceHost from "./DeviceHost.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import * as EasDeviceHost from "./EasDeviceHost.ts"; +import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { makeExpoApi, resolveExpoCredential } from "./ExpoApi.ts"; import * as Exit from "effect/Exit"; import * as LocalDeviceHost from "./LocalDeviceHost.ts"; @@ -140,6 +146,10 @@ export class DeviceService extends Context.Service< readonly easAccount: ( input: DeviceEasAccountInput, ) => Effect.Effect; + /** Who a Simlock agent token is; without a gateway or token, this machine's agent-device's. */ + readonly simlockIdentity: ( + input: DeviceSimlockIdentityInput, + ) => Effect.Effect; /** * The agent-device flags that reach the device: its config and session, * and the id agent-device selects it by, which can differ from its id here. @@ -150,9 +160,10 @@ export class DeviceService extends Context.Service< deviceId: DeviceId; }) => Effect.Effect< { - readonly agentDeviceId: string; + readonly agentDeviceId: string | null; readonly session: string; readonly args: ReadonlyArray; + readonly installDirectory?: string | undefined; }, DeviceError >; @@ -247,7 +258,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), configureAgent: ( hostId: DeviceHostId, - endpoint: DeviceHost.AgentDaemonEndpoint, + endpoint: DeviceHost.AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, deviceId?: DeviceId, ) => Effect.Effect = (hostId) => Effect.fail( @@ -284,6 +295,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* supportsToolUpdate: installTool !== undefined, supportsToolInspection: true, supportsEasHosts: true, + supportsSimlockHosts: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", hostStatuses: {}, @@ -299,6 +311,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const launcherLock = yield* Semaphore.make(1); /** Launchers bound since startup, by agent-device session. */ const boundLaunchers = new Map(); + /** Launchers on a session the host leases to every thread; the host ends it, not a retiring thread. */ + const hostSessionLaunchers = new Set(); // Binding and pruning read sessions under the lock, so neither acts on a session list the other has outdated. // A retired session's agent-device session ends before its launcher goes, and a reopen of the same // device waits for the lock, so the close cannot end the reopened session. @@ -313,9 +327,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); const retired = [...boundLaunchers].filter(([session]) => !live.has(session)); for (const [session] of retired) boundLaunchers.delete(session); + const ending = retired.filter(([session]) => !hostSessionLaunchers.delete(session)); const release = launchers.release; if (release) - yield* Effect.forEach(retired, ([, launcher]) => release(launcher), { + yield* Effect.forEach(ending, ([, launcher]) => release(launcher), { concurrency: "unbounded", discard: true, }); @@ -703,7 +718,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); } - if (device.platform === "android" && !device.physical) { + // Hosts that route their own streams never encode emulator video with serve-emu's ffmpeg. + if (device.platform === "android" && !device.physical && !ready.upstream) { // Resolve the override on the device host the way its hub does. const encoders = yield* ready.run(ready.nodePath, [ "-e", @@ -1004,6 +1020,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); }), + simlockIdentity: Effect.fn("DeviceService.simlockIdentity")(function () { + return Effect.fail( + new DeviceSimlockIdentityError({ + reason: "Simlock hosts are unavailable in this device service.", + }), + ); + }), agentTarget: Effect.fn("DeviceService.agentTarget")(function* (input) { const host = yield* resolveHost(input.hostId); const ready = yield* agentReadinessIfSupported(input.hostId); @@ -1014,39 +1037,51 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", }); - const { configPath, agentDeviceId } = yield* lifecycleLock.withPermit( - Effect.gen(function* () { - if (hosts.get(host.id) !== host) - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: "Host configuration changed. Retry the operation.", - }); - if (ready.devices.agentEndpoint) { - const endpoint = yield* ready.devices.agentEndpoint(input.deviceId); + const { configPath, agentDeviceId, boundSession, installDirectory } = + yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "Host configuration changed. Retry the operation.", + }); + if (ready.devices.agentEndpoint) { + const endpoint = yield* ready.devices.agentEndpoint(input.deviceId); + return { + configPath: yield* configureAgent(input.hostId, endpoint, input.deviceId), + agentDeviceId: endpoint.agentDeviceId, + boundSession: endpoint.session, + installDirectory: endpoint.installDirectory, + }; + } + if (!ready.agentDevice) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "This host has no agent-device daemon.", + }); return { - configPath: yield* configureAgent(input.hostId, endpoint, input.deviceId), - agentDeviceId: endpoint.agentDeviceId, + configPath: yield* configureAgent(input.hostId, ready.agentDevice), + agentDeviceId: input.deviceId, + boundSession: undefined, + installDirectory: undefined, }; - } - if (!ready.agentDevice) - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: "This host has no agent-device daemon.", - }); - return { - configPath: yield* configureAgent(input.hostId, ready.agentDevice), - agentDeviceId: input.deviceId, - }; - }), - ); - const session = yield* agentDeviceSession( + }), + ); + const ownSession = yield* agentDeviceSession( input.threadId, input.hostId, input.deviceId, ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; + if (boundSession) hostSessionLaunchers.add(ownSession); + const session = boundSession ?? ownSession; + return { + agentDeviceId, + session, + args: ["--config", configPath, "--session", session], + installDirectory, + }; }), agentLauncher: Effect.fn("DeviceService.agentLauncher")(function* ({ targetArgs, @@ -1139,7 +1174,7 @@ export const make = Effect.gen(function* () { const configureAgent = Effect.fn("DeviceService.configureAgent")( function* ( hostId: DeviceHostId, - endpoint: DeviceHost.AgentDaemonEndpoint, + endpoint: DeviceHost.AgentDaemonEndpoint & { readonly stateDir?: string | undefined }, deviceId?: DeviceId, ) { const file = yield* configPath(hostId, deviceId); @@ -1290,6 +1325,23 @@ export const make = Effect.gen(function* () { yield* Effect.context>>(); const easContext = yield* Effect.context>>(); + const simlockContext = + yield* Effect.context>>(); + const simlockAgentCli = Effect.fn("DeviceService.simlockAgentCli")( + function* (hostId: string) { + const nodePath = yield* resolveNodeExecutable("Device automation"); + const tool = yield* ensureAgentDevice(config.baseDir).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId, step: "installing agent tools", cause }), + ), + ); + return { nodePath, entryPath: tool.entryPath }; + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + ); const configured = new Map(); const unchanged = (current: DeviceHostConfig, previous: DeviceHostConfig) => current.kind === "ssh" && previous.kind === "ssh" @@ -1334,6 +1386,24 @@ export const make = Effect.gen(function* () { if (configured.has(host.id)) continue; added = true; const hostScope = yield* Scope.fork(scope); + if (host.kind === "simlock") { + // Closing this scope releases the host's leases back to the pool. + const instance = yield* SimlockDeviceHost.make(host, simlockAgentCli(host.id)).pipe( + Effect.provideService(Scope.Scope, hostScope), + Effect.provide(simlockContext), + ); + hosts.set(host.id, instance); + configured.set(host.id, { config: host, scope: hostScope }); + // Ask the gateway now, so a refused token shows in Settings before anyone opens a device. + if ((yield* settings.getSettings).enableDeviceSupport) + yield* instance + .platformAvailability("ios") + .pipe( + Effect.andThen(service.withLifecycleLock(service.refreshHosts)), + Effect.forkIn(hostScope), + ); + continue; + } if (host.kind === "eas") { // Closing this scope stops the host's running sessions, so a changed // limit or token never leaves a billed session behind. @@ -1375,14 +1445,22 @@ export const make = Effect.gen(function* () { ); }); const changes = yield* settings.subscribeChanges; - // Settings keep EAS hosts apart from SSH hosts; an id both lists use stays the SSH host's. + // Each kind has its own settings list; an id two lists share stays the first one's. const configuredHosts = (value: { readonly deviceHosts: ReadonlyArray; readonly easDeviceHosts: ReadonlyArray; - }): ReadonlyArray => [ - ...value.deviceHosts, - ...value.easDeviceHosts.filter((host) => !value.deviceHosts.some((ssh) => ssh.id === host.id)), - ]; + readonly simlockDeviceHosts: ReadonlyArray; + readonly simlockDeviceHostsEnabled: boolean; + }): ReadonlyArray => { + const hosts: Array = [...value.deviceHosts]; + for (const host of [ + ...value.easDeviceHosts, + ...(value.simlockDeviceHostsEnabled ? value.simlockDeviceHosts : []), + ]) { + if (!hosts.some((existing) => existing.id === host.id)) hosts.push(host); + } + return hosts; + }; yield* reconcile(configuredHosts(yield* settings.getSettings)); yield* changes.pipe( Stream.runForEach((value) => reconcile(configuredHosts(value))), @@ -1423,9 +1501,62 @@ export const make = Effect.gen(function* () { ), ); + const simlockIdentity: DeviceService["Service"]["simlockIdentity"] = Effect.fn( + "DeviceService.simlockIdentity", + )( + function* (input) { + const environment = yield* HostProcess.Environment; + const gatewayUrl = ( + input.gatewayUrl || SimlockDeviceHost.machineSimlockToken(environment)?.gatewayUrl + )?.replace(/\/$/, ""); + if (!gatewayUrl) + return yield* new DeviceSimlockIdentityError({ + reason: "This machine's agent-device does not point at a Simlock gateway.", + }); + const savedHost = + input.hostId === undefined + ? undefined + : (yield* settings.getSettings).simlockDeviceHosts.find( + (host) => host.id === input.hostId, + ); + const token = input.agentToken + ? { agentToken: input.agentToken, source: "entered" as const } + : SimlockDeviceHost.resolveSimlockToken(gatewayUrl, savedHost, environment); + if (!token) + return yield* new DeviceSimlockIdentityError({ + reason: savedHost?.agentToken + ? "The saved token is for another gateway. Enter one for this gateway." + : "No agent token for this gateway on this machine.", + }); + const identity = yield* SimlockDeviceHost.simlockIdentity(gatewayUrl, token.agentToken).pipe( + Effect.mapError( + (cause) => + new DeviceSimlockIdentityError({ + reason: SimlockDeviceHost.describeGatewayFailure( + cause, + gatewayUrl, + token.source === "env" ? "this machine" : "this environment", + ), + }), + ), + ); + if (identity.role !== "agent") + return yield* new DeviceSimlockIdentityError({ + reason: `That is a ${identity.role} token. Create one with --role agent.`, + }); + return { gatewayUrl, signedInAs: identity.name, tokenSource: token.source }; + }, + Effect.catchTags({ + ServerSettingsError: () => + Effect.fail(new DeviceSimlockIdentityError({ reason: "Could not read the saved token." })), + }), + Effect.provide(simlockContext), + ); + return { ...service, easAccount, + simlockIdentity, }; }); diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 31b0ed0b1291..5802956e699b 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { EasDeviceHostConfig } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -455,6 +456,24 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("reports iOS unavailable on a server with no Expo credential", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-expo-home-" }); + const host = yield* makeHost( + fakeExpo(), + config({ accessToken: "" }), + yield* Scope.make(), + ).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, {}), + ); + const [ios] = (yield* host.summary).platforms; + expect(ios).toMatchObject({ platform: "ios", available: false }); + expect(ios?.reason).toContain("No Expo credential on the server"); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("stops a session that is still starting when the host stops", () => Effect.gen(function* () { const previewGate = yield* makeGate; diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index 72c88de1afe2..da8a665d4b4e 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -830,16 +830,22 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), ); + // Each server signs in on its own, so one with no Expo credential answers for itself. Reading + // the credential is local; whether Expo accepts it is only known once a session starts. const platformAvailability = (platform: DevicePlatform) => - Effect.succeed( - platform === "ios" - ? { platform, available: true } - : { - platform, - available: false, - reason: "EAS Simulator Android emulators are not supported yet.", - }, - ); + platform === "ios" + ? resolveExpoCredential(config.accessToken).pipe( + Effect.provide(services), + Effect.as({ platform, available: true }), + Effect.catch((error) => + Effect.succeed({ platform, available: false, reason: error.message }), + ), + ) + : Effect.succeed({ + platform, + available: false, + reason: "EAS Simulator Android emulators are not supported yet.", + }); const summary: Effect.Effect = Effect.all([ platformAvailability("ios"), diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts new file mode 100644 index 000000000000..1b670a06cd07 --- /dev/null +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { SimlockDeviceHostConfig } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as SimlockDeviceHost from "./SimlockDeviceHost.ts"; + +const GATEWAY = "https://simlock.test:4700"; + +const config = (overrides: Partial = {}): SimlockDeviceHostConfig => ({ + kind: "simlock", + id: "pool", + label: "Device pool", + gatewayUrl: `${GATEWAY}/`, + agentToken: "agent-token", + maxLeases: 2, + maxLeaseMinutes: 60, + ...overrides, +}); + +const CATALOG = { + platforms: [ + { + platform: "ios", + modelRuntimes: { "iPhone 17 Pro": ["27.0"], "Apple TV 4K": ["27.0"], "iPhone 12": [] }, + modelClasses: { "iPhone 17 Pro": "phone", "Apple TV 4K": "tv", "iPhone 12": "phone" }, + }, + { + platform: "android", + modelRuntimes: { "Pixel 9": ["36"], "Pixel 8": ["36"] }, + modelWorkers: { "Pixel 9": ["worker-a"], "Pixel 8": [] }, + }, + ], +}; + +/** The gateway's HTTP API and T3's agent-device CLI, recording what was asked of them. */ +const fakePool = ( + options: { + readonly failOpen?: boolean; + readonly holdOpen?: { + readonly entered: Deferred.Deferred; + readonly proceed: Deferred.Deferred; + }; + } = {}, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const requests: Array = []; + const commands: Array<{ readonly args: ReadonlyArray; readonly cwd?: string }> = []; + let leases = 0; + let renewStatus = 200; + let catalog: unknown = CATALOG; + const http = HttpClient.make((request) => + Effect.sync(() => { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + const json = (body: unknown, status = 200) => + HttpClientResponse.fromWeb(request, Response.json(body, { status })); + if (request.headers.authorization !== "Bearer agent-token") + return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401); + if (url.pathname === "/v1/catalog") return json(catalog); + if (url.pathname.endsWith("/renew")) return json({}, renewStatus); + if (request.method === "DELETE") return json({}, 202); + return json({}, 404); + }), + ); + const runner = ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + const args = input.args.slice(1); + commands.push({ args, ...(input.cwd ? { cwd: input.cwd } : {}) }); + const command = args[0]; + const session = args[args.indexOf("--session") + 1]!; + const configFile = args[args.indexOf("--config") + 1]!; + const stateDir = ( + JSON.parse(yield* fs.readFileString(configFile)) as { stateDir: string } + ).stateDir; + if (command === "open") { + if (options.failOpen) { + return { + stdout: JSON.stringify({ + error: { message: "No device matches.", hint: "Retry." }, + }), + stderr: "", + code: ChildProcessSpawner.ExitCode(1), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + } + if (options.holdOpen) { + yield* Deferred.succeed(options.holdOpen.entered, undefined); + yield* Deferred.await(options.holdOpen.proceed); + } + const dir = path.join(stateDir, "remote-connections"); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, `${session}.json`), + JSON.stringify({ leaseId: `lease-${++leases}` }), + ); + } + if (command === "screenshot") yield* fs.writeFile(args[1]!, new Uint8Array([1, 2, 3])); + return { + stdout: "{}", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }).pipe(Effect.orDie), + }); + const ran = (command: string) => commands.filter(({ args }) => args[0] === command); + return { + http, + runner, + requests, + ran, + endLeases: () => { + renewStatus = 404; + }, + setCatalog: (next: unknown) => { + catalog = next; + }, + }; + }); + +const layer = Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-simlock-host-" }), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const makeHost = ( + pool: Effect.Success>, + hostConfig: SimlockDeviceHostConfig, + scope: Scope.Scope, +) => + SimlockDeviceHost.make( + hostConfig, + Effect.succeed({ nodePath: "node", entryPath: "/tools/agent-device.mjs" }), + ).pipe( + Effect.provideService(HttpClient.HttpClient, pool.http), + Effect.provideService(ProcessRunner.ProcessRunner, pool.runner), + Effect.provideService(Scope.Scope, scope), + ); + +describe("Simlock device host", () => { + it.effect("leases a pool device for agents and serves its screen and input to the panel", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const fs = yield* FileSystem.FileSystem; + const workspace = yield* fs.makeTempDirectoryScoped(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + + const catalog = (yield* ready.devices.list).devices; + // Only models a worker can make right now, and only phones and tablets. + expect(catalog.map((device) => [device.id, device.version])).toEqual([ + ["simlock:ios:iPhone 17 Pro", "iOS 27.0"], + ["simlock:android:Pixel 9", "Android 36"], + ]); + + const leaseId = yield* ready.devices.boot(catalog[1]!, { workspaceRoot: workspace }); + expect(leaseId).toBe("lease-1"); + const [open] = pool.ran("open"); + expect(open?.args.slice(0, 5)).toEqual([ + "open", + "--platform", + "android", + "--device", + "Pixel 9", + ]); + expect(open?.cwd).toBe(yield* fs.realPath(workspace)); + expect(pool.ran("connect")[0]?.args).toContain(`${GATEWAY}/v1/agent-device`); + + const binding = yield* ready.devices.agentEndpoint!(leaseId); + expect(binding).toMatchObject({ + baseUrl: `${GATEWAY}/v1/agent-device`, + token: "agent-token", + agentDeviceId: null, + session: open?.args[open.args.indexOf("--session") + 1], + installDirectory: yield* fs.realPath(workspace), + }); + expect((yield* ready.devices.list).devices.at(-1)).toMatchObject({ + id: "lease-1", + name: "Pixel 9", + booted: true, + }); + + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/lease-1/stream.avcc", + new URLSearchParams(), + ), + ).toEqual({ + url: `${GATEWAY}/v1/leases/lease-1/stream`, + headers: { authorization: "Bearer agent-token" }, + protocols: [], + }); + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/ws", + new URLSearchParams({ device: "lease-1" }), + ), + ).toEqual({ + url: `${GATEWAY}/v1/leases/lease-1/input`, + headers: { authorization: "Bearer agent-token" }, + protocols: [], + }); + expect( + yield* ready.upstream!("/vendor/serve-sim/helper/lease-1/config", new URLSearchParams()), + ).toBeNull(); + expect( + yield* ready.upstream!( + "/vendor/serve-sim/helper/ws", + new URLSearchParams({ device: "lease-2" }), + ), + ).toBeNull(); + expect([...(yield* ready.devices.screenshot({ ...catalog[1]!, id: leaseId }))]).toEqual([ + 1, 2, 3, + ]); + + yield* ready.devices.shutdown({ id: leaseId, platform: "android" }); + expect(pool.ran("close")).toHaveLength(1); + expect((yield* ready.devices.list).devices.some((device) => device.booted)).toBe(false); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("caps leases at the limit", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ maxLeases: 1 }), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + yield* ready.devices.boot(iphone!); + + const refused = yield* ready.devices.boot(iphone!).pipe(Effect.flip); + expect(refused.message).toContain("limited to 1 device"); + expect(pool.ran("open")).toHaveLength(1); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("reports its platforms unavailable when the gateway refuses the agent token", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ agentToken: "revoked" }), scope); + expect(yield* host.platformAvailability("ios")).toEqual({ + platform: "ios", + available: false, + reason: "The Simlock gateway refused the agent token for Device pool.", + }); + expect((yield* host.summary).platforms.every((platform) => !platform.available)).toBe(true); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("sends saved and machine tokens only to the gateway each belongs to", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const availability = (baseUrl: string) => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config({ agentToken: "" }), scope).pipe( + Effect.provideService(HostProcess.Environment, { + AGENT_DEVICE_DAEMON_BASE_URL: baseUrl, + AGENT_DEVICE_DAEMON_AUTH_TOKEN: "agent-token", + }), + ); + const result = yield* host.platformAvailability("ios"); + yield* Scope.close(scope, Exit.void); + return result; + }); + + expect( + SimlockDeviceHost.resolveSimlockToken( + "https://elsewhere.example:4700", + config({ agentToken: "saved-token" }), + {}, + ), + ).toBeNull(); + + expect(yield* availability(`${GATEWAY}/v1/agent-device`)).toEqual({ + platform: "ios", + available: true, + }); + const requests = pool.requests.length; + expect(yield* availability("https://elsewhere.example:4700/v1/agent-device")).toEqual({ + platform: "ios", + available: false, + reason: "Add an agent token for Device pool in Settings.", + }); + expect(pool.requests).toHaveLength(requests); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("lists a platform again as soon as its workers return", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + pool.setCatalog({ platforms: [] }); + expect((yield* ready.devices.list).devices).toEqual([]); + // Mid-reconnect, the gateway knows the Android models but no worker that makes them. + pool.setCatalog({ + platforms: [ + CATALOG.platforms[0]!, + { ...CATALOG.platforms[1]!, modelWorkers: { "Pixel 9": [], "Pixel 8": [] } }, + ], + }); + const during = (yield* ready.devices.list).devices; + expect(during.some((device) => device.platform === "android")).toBe(false); + pool.setCatalog(CATALOG); + const after = (yield* ready.devices.list).devices; + expect(after.some((device) => device.platform === "android")).toBe(true); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("closes the session of a failed open so no lease is left behind", () => + Effect.gen(function* () { + const pool = yield* fakePool({ failOpen: true }); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const failure = yield* ready.devices.boot(iphone!).pipe(Effect.flip); + expect(String(failure.cause)).toContain("No device matches. Retry."); + expect(pool.ran("close")).toHaveLength(1); + // The failed open freed its slot. + expect(yield* ready.devices.boot(iphone!).pipe(Effect.flip)).toBeDefined(); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("keeps a watched lease renewed and stops listing it once Simlock ends it", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const scope = yield* Scope.make(); + const host = yield* makeHost(pool, config(), scope); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const leaseId = yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("2 minutes"); + expect(pool.requests).toContain(`POST /v1/leases/${leaseId}/renew`); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + true, + ); + + pool.endLeases(); + yield* TestClock.adjust("2 minutes"); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + false, + ); + yield* Scope.close(scope, Exit.void); + expect(pool.ran("close")).toHaveLength(0); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("returns a lease whose open finishes after the host stopped", () => + Effect.gen(function* () { + const holdOpen = { + entered: yield* Deferred.make(), + proceed: yield* Deferred.make(), + }; + const pool = yield* fakePool({ holdOpen }); + const host = yield* makeHost(pool, config(), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const opening = yield* Effect.forkChild(ready.devices.boot(iphone!)); + yield* Deferred.await(holdOpen.entered); + yield* host.stop; + yield* Deferred.succeed(holdOpen.proceed, undefined); + expect(Exit.isFailure(yield* Fiber.await(opening))).toBe(true); + expect(pool.ran("close")).toHaveLength(1); + expect((yield* ready.devices.list).devices.every((device) => !device.booted)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + + it.effect("holds a lease for its full maximum age, then returns it", () => + Effect.gen(function* () { + const pool = yield* fakePool(); + const host = yield* makeHost(pool, config({ maxLeaseMinutes: 5 }), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("299 seconds"); + expect(pool.ran("close")).toHaveLength(0); + yield* TestClock.adjust("1 second"); + expect(pool.ran("close")).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts new file mode 100644 index 000000000000..75476f0333ce --- /dev/null +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -0,0 +1,663 @@ +/** + * Alpha: devices leased from a Simlock gateway the user runs. Opening a device + * takes a lease through the gateway's agent-device front, so agents drive it + * with the same session; closing it returns the device to the pool, wiped. + * A lease left by a server that crashed ends one TTL after its last renewal. + */ +import { + DeviceBootError, + type DeviceHostSummary, + DeviceHostUnavailableError, + DeviceOperationError, + type DevicePlatform, + type DevicePlatformAvailability, + type SimlockDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { writeAgentDeviceConfig } from "./AgentDeviceTarget.ts"; +import * as DeviceHost from "./DeviceHost.ts"; + +const CATALOG_TTL_MS = 60_000; +const REQUEST_TIMEOUT = Duration.seconds(15); +// The gateway queues a lease request for up to five minutes when every device is busy. +const OPEN_TIMEOUT = Duration.minutes(7); +const COMMAND_TIMEOUT = Duration.minutes(2); +// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL. +const RENEW_INTERVAL = Duration.minutes(2); +const LEASE_TTL_MS = 10 * 60_000; +const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/; +const INPUT_PATH = "/vendor/serve-sim/helper/ws"; + +const Catalog = Schema.Struct({ + platforms: Schema.Array( + Schema.Struct({ + platform: Schema.String, + modelRuntimes: Schema.Record(Schema.String, Schema.Array(Schema.String)), + modelClasses: Schema.optional(Schema.Record(Schema.String, Schema.String)), + modelWorkers: Schema.optional(Schema.Record(Schema.String, Schema.Array(Schema.String))), + }), + ), +}); +const decodeCatalog = Schema.decodeUnknownEffect(Catalog); +const decodeSessionRecord = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ leaseId: Schema.String })), +); +const decodeCliFailure = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + error: Schema.Struct({ message: Schema.String, hint: Schema.optional(Schema.String) }), + }), + ), +); + +interface Lease { + readonly leaseId: string; + readonly platform: DevicePlatform; + readonly name: string; + readonly version: string; + readonly session: string; + readonly dir: string; + readonly configPath: string; + readonly installDirectory: string; +} + +const catalogId = (platform: DevicePlatform, model: string) => `simlock:${platform}:${model}`; +const parseCatalogId = (id: string) => { + const match = /^simlock:(ios|android):(.+)$/.exec(id); + return match ? { platform: match[1] as DevicePlatform, model: match[2]! } : null; +}; +const platformVersion = (platform: DevicePlatform, runtime: string | undefined) => + `${platform === "ios" ? "iOS" : "Android"}${runtime ? ` ${runtime}` : ""}`; + +const AGENT_DEVICE_PATH = "/v1/agent-device"; +const sameOrigin = (left: string, right: string) => { + try { + return new URL(left).origin === new URL(right).origin; + } catch { + return false; + } +}; + +/** + * The gateway and token this machine's agent-device uses, from agent-device's own remote-daemon + * variables, which Simlock tells its clients to set. Null unless they point at a Simlock gateway. + */ +export const machineSimlockToken = (environment: NodeJS.ProcessEnv) => { + const base = environment.AGENT_DEVICE_DAEMON_BASE_URL?.trim().replace(/\/+$/, ""); + const agentToken = environment.AGENT_DEVICE_DAEMON_AUTH_TOKEN?.trim(); + if (!base?.endsWith(AGENT_DEVICE_PATH) || !agentToken) return null; + const gatewayUrl = base.slice(0, -AGENT_DEVICE_PATH.length); + return URL.canParse(gatewayUrl) ? { gatewayUrl, agentToken } : null; +}; + +/** + * The saved token, else this machine's agent-device token; each only for the gateway it belongs to, + * so a URL being edited never receives either. + */ +export const resolveSimlockToken = ( + gatewayUrl: string, + saved: { readonly gatewayUrl: string; readonly agentToken: string } | undefined, + environment: NodeJS.ProcessEnv, +) => { + if (saved?.agentToken && sameOrigin(saved.gatewayUrl, gatewayUrl)) + return { agentToken: saved.agentToken, source: "settings" as const }; + const machine = machineSimlockToken(environment); + return machine && sameOrigin(machine.gatewayUrl, gatewayUrl) + ? { agentToken: machine.agentToken, source: "env" as const } + : null; +}; + +/** What went wrong talking to a gateway, without the gateway's own text. */ +export const describeGatewayFailure = (cause: unknown, gatewayUrl: string, tokenOwner: string) => { + if (Cause.isTimeoutError(cause)) return `Could not reach the Simlock gateway at ${gatewayUrl}.`; + if (!HttpClientError.isHttpClientError(cause)) + return `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`; + if (cause.reason._tag === "StatusCodeError") { + const status = cause.reason.response.status; + return status === 401 || status === 403 + ? `The Simlock gateway refused the agent token for ${tokenOwner}.` + : `The Simlock gateway at ${gatewayUrl} answered HTTP ${status}.`; + } + return cause.reason._tag === "TransportError" + ? `Could not reach the Simlock gateway at ${gatewayUrl}.` + : `The Simlock gateway at ${gatewayUrl} sent an answer T3 could not read.`; +}; + +const decodeIdentity = Schema.decodeUnknownEffect( + Schema.Struct({ name: Schema.String, role: Schema.String }), +); + +/** Who the gateway says a token is, via GET /v1/identity. */ +export const simlockIdentity = Effect.fn("SimlockDeviceHost.identity")(function* ( + gatewayUrl: string, + agentToken: string, +) { + const http = yield* HttpClient.HttpClient; + return yield* http + .execute( + HttpClientRequest.get(`${gatewayUrl.replace(/\/$/, "")}/v1/identity`).pipe( + HttpClientRequest.bearerToken(agentToken), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeIdentity), + Effect.scoped, + Effect.timeout(REQUEST_TIMEOUT), + ); +}); + +/** T3's own agent-device CLI, installed on first use. */ +export type AgentDeviceCli = Effect.Effect< + { readonly nodePath: string; readonly entryPath: string }, + DeviceHost.DeviceHostError | NodeRuntimeUnavailableError +>; + +export const make = Effect.fn("SimlockDeviceHost.make")(function* ( + config: SimlockDeviceHostConfig, + agentDeviceCli: AgentDeviceCli, +) { + const http = yield* HttpClient.HttpClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const server = yield* ServerConfig.ServerConfig; + const services = yield* Effect.context(); + const environment = yield* HostProcess.Environment; + const hostId = config.id; + const gateway = config.gatewayUrl.replace(/\/$/, ""); + const agentToken = resolveSimlockToken(config.gatewayUrl, config, environment)?.agentToken ?? ""; + const agentDeviceUrl = `${gateway}/v1/agent-device`; + const hostDir = path.join(server.stateDir, "device", "simlock", hostId); + const leases = new Map(); + let starting = 0; + // Bumped by every stop, so an open that finishes after one returns its lease instead of keeping it. + let stops = 0; + const slotLock = yield* Semaphore.make(1); + const keepAlive = yield* FiberMap.make(); + let catalog: { + readonly at: number; + readonly devices: ReadonlyArray; + } | null = null; + // The last answer from the gateway; empty until it gives one. + let platforms: ReadonlyArray = []; + let ready: DeviceHost.DeviceHostReady | null = null; + let cli: { readonly nodePath: string; readonly entryPath: string } | null = null; + + const unavailable = (reason: string, cause?: unknown) => + new DeviceHostUnavailableError({ + hostId, + hostLabel: config.label, + reason, + ...(cause === undefined ? {} : { cause }), + }); + + const request = ( + method: "GET" | "POST" | "DELETE", + route: string, + body?: Readonly>, + ) => + http + .execute( + (method === "GET" + ? HttpClientRequest.get(`${gateway}${route}`) + : method === "POST" + ? HttpClientRequest.post(`${gateway}${route}`).pipe( + HttpClientRequest.bodyJsonUnsafe(body ?? {}), + ) + : HttpClientRequest.delete(`${gateway}${route}`) + ).pipe(HttpClientRequest.bearerToken(agentToken)), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.scoped, + Effect.timeout(REQUEST_TIMEOUT), + ); + + const agentEnvironment: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries({ + ...environment, + ELECTRON_RUN_AS_NODE: "1", + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + NO_COLOR: "1", + FORCE_COLOR: "0", + }).filter( + ([name]) => + !/^AGENT_DEVICE_(DAEMON_BASE_URL|DAEMON_AUTH_TOKEN|CONFIG|STATE_DIR|SESSION)$/.test(name), + ), + ); + + /** Runs T3's own agent-device CLI against the lease's session; its failure message is the reason. */ + const agentDevice = Effect.fn("SimlockDeviceHost.agentDevice")(function* ( + lease: Pick, + args: ReadonlyArray, + options: { readonly cwd?: string; readonly timeout?: Duration.Input } = {}, + ) { + if (!cli) return yield* unavailable(`${config.label} is not ready.`); + const result = yield* runner + .run({ + command: cli.nodePath, + args: [ + cli.entryPath, + ...args, + "--session", + lease.session, + "--config", + lease.configPath, + "--json", + ], + env: agentEnvironment, + ...(options.cwd ? { cwd: options.cwd } : {}), + timeout: options.timeout ?? COMMAND_TIMEOUT, + }) + .pipe(Effect.mapError((cause) => unavailable("agent-device could not run.", cause))); + if (result.code !== 0) { + const failure = decodeCliFailure(result.stdout); + return yield* unavailable( + Option.match(failure, { + onNone: () => `agent-device ${args[0]} failed (exit code ${result.code ?? "none"}).`, + onSome: ({ error }) => (error.hint ? `${error.message} ${error.hint}` : error.message), + }), + ); + } + return result.stdout; + }); + + const fetchCatalog = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices; + if (!agentToken) { + return yield* unavailable(`Add an agent token for ${config.label} in Settings.`); + } + const decoded = yield* request("GET", "/v1/catalog").pipe( + Effect.flatMap(decodeCatalog), + Effect.mapError((cause) => + unavailable(describeGatewayFailure(cause, gateway, config.label), cause), + ), + ); + const byPlatform = decoded.platforms.map((entry) => { + if (entry.platform !== "ios" && entry.platform !== "android") return null; + const platform: DevicePlatform = entry.platform; + return Object.entries(entry.modelRuntimes).flatMap(([model, runtimes]) => { + const kind = entry.modelClasses?.[model]; + const workers = entry.modelWorkers?.[model]; + if (runtimes.length === 0 || (workers !== undefined && workers.length === 0)) return []; + if (kind !== undefined && kind !== "phone" && kind !== "tablet") return []; + return [ + { + id: catalogId(platform, model), + platform, + name: model, + version: platformVersion(platform, runtimes.at(-1)), + booted: false, + physical: false, + } satisfies DeviceHost.HostDevice, + ]; + }); + }); + const devices = byPlatform.flatMap((entry) => entry ?? []); + // A platform with nothing to offer is usually its workers reconnecting; caching that would + // hide it for the TTL, so only a catalog with devices on every platform is kept. + catalog = + devices.length > 0 && byPlatform.every((entry) => entry === null || entry.length > 0) + ? { at: now, devices } + : null; + return devices; + }); + + const loadCatalog = fetchCatalog.pipe( + Effect.tap((devices) => + Effect.sync(() => { + platforms = (["ios", "android"] as const).map((platform) => + devices.some((device) => device.platform === platform) + ? { platform, available: true } + : { + platform, + available: false, + reason: `The pool has no ${platform === "ios" ? "iOS" : "Android"} devices.`, + }, + ); + }), + ), + Effect.tapError((error) => + Effect.sync(() => { + platforms = (["ios", "android"] as const).map((platform) => ({ + platform, + available: false, + reason: error.reason, + })); + }), + ), + ); + + const forget = (lease: Lease) => + Effect.suspend(() => { + leases.delete(lease.leaseId); + return fs.remove(lease.dir, { recursive: true, force: true }).pipe(Effect.ignore); + }); + + /** Ends the lease through its session, or over HTTP when the session is gone. */ + const release = Effect.fn("SimlockDeviceHost.release")(function* (lease: Lease) { + yield* agentDevice(lease, ["close"]).pipe( + Effect.catch(() => request("DELETE", `/v1/leases/${encodeURIComponent(lease.leaseId)}`)), + Effect.tapError((cause) => + Effect.logWarning("Could not release Simlock lease", { leaseId: lease.leaseId, cause }), + ), + Effect.ignore, + ); + yield* forget(lease); + }); + + /** Renews until the lease ends on its own or reaches its maximum age, then releases it. */ + const renewLoop = Effect.fn("SimlockDeviceHost.renewLoop")(function* (lease: Lease) { + const deadline = + (yield* Clock.currentTimeMillis) + + Duration.toMillis(Duration.minutes(config.maxLeaseMinutes)); + while (true) { + const left = deadline - (yield* Clock.currentTimeMillis); + yield* Effect.sleep(Duration.millis(Math.min(left, Duration.toMillis(RENEW_INTERVAL)))); + if ((yield* Clock.currentTimeMillis) >= deadline) break; + const renewed = yield* request( + "POST", + `/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`, + { ttlMs: LEASE_TTL_MS }, + ).pipe(Effect.result); + if (renewed._tag === "Success") continue; + const error = renewed.failure; + // Simlock ended it (lease_lost, an agent's close, or its TTL): stop listing it. + if ( + error._tag === "HttpClientError" && + error.reason._tag === "StatusCodeError" && + error.reason.response.status === 404 + ) { + return yield* forget(lease); + } + yield* Effect.logWarning("Could not renew Simlock lease", { leaseId: lease.leaseId, error }); + } + yield* Effect.logInfo("Releasing Simlock lease at its maximum age", { + leaseId: lease.leaseId, + }); + yield* release(lease); + }); + + const reserveSlot = slotLock.withPermit( + Effect.suspend(() => { + if (leases.size + starting >= config.maxLeases) { + return Effect.fail( + unavailable( + `${config.label} is limited to ${config.maxLeases} ${config.maxLeases === 1 ? "device" : "devices"} at once. Close one in the Device panel first.`, + ), + ); + } + starting++; + return Effect.void; + }), + ); + + const openLease = Effect.fn("SimlockDeviceHost.openLease")(function* ( + device: DeviceHost.HostDevice, + context: DeviceHost.DeviceOpenContext | undefined, + ) { + const target = parseCatalogId(device.id); + if (!target) { + return yield* new DeviceBootError({ + hostId, + deviceId: device.id, + reason: "launch_failed", + cause: new Error("This Simlock device is no longer leased. Open it again from the list."), + }); + } + const suffix = (yield* Random.nextIntBetween(0, 0xffffff)).toString(16).padStart(6, "0"); + const session = `t3-${suffix}`; + const dir = path.join(hostDir, suffix); + const configPath = path.join(dir, "agent-device.json"); + yield* writeAgentDeviceConfig(configPath, { + baseUrl: agentDeviceUrl, + token: agentToken, + stateDir: dir, + }).pipe( + Effect.provide(services), + Effect.mapError((cause) => unavailable("Could not write the agent-device config.", cause)), + ); + // agent-device ties installs to the directory that opened the session. + const installDirectory = context?.workspaceRoot + ? yield* fs.realPath(context.workspaceRoot).pipe(Effect.orElseSucceed(() => dir)) + : dir; + const pending = { session, configPath }; + const stopsAtOpen = stops; + return yield* Effect.gen(function* () { + yield* agentDevice(pending, ["connect", "proxy", "--daemon-base-url", agentDeviceUrl]); + yield* agentDevice( + pending, + ["open", "--platform", target.platform, "--device", target.model], + { cwd: installDirectory, timeout: OPEN_TIMEOUT }, + ); + const record = yield* fs + .readFileString(path.join(dir, "remote-connections", `${session}.json`)) + .pipe( + Effect.flatMap(decodeSessionRecord), + Effect.mapError((cause) => unavailable("agent-device recorded no Simlock lease.", cause)), + ); + const opened: Lease = { + leaseId: record.leaseId, + platform: target.platform, + name: target.model, + version: device.version, + session, + dir, + configPath, + installDirectory, + }; + if (stops !== stopsAtOpen) + return yield* unavailable("Device support stopped while the device was opening."); + leases.set(opened.leaseId, opened); + yield* FiberMap.run(keepAlive, opened.leaseId, renewLoop(opened)); + return opened.leaseId; + }).pipe( + // A failed or interrupted open may still hold a lease: close the session either way. + Effect.onExit((exit) => + exit._tag === "Success" + ? Effect.void + : agentDevice(pending, ["close"]).pipe( + Effect.ignore, + Effect.andThen(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)), + ), + ), + Effect.mapError((error) => + error._tag === "DeviceHostUnavailableError" + ? new DeviceBootError({ + hostId, + deviceId: device.id, + reason: "launch_failed", + cause: new Error(error.reason), + }) + : error, + ), + ); + }); + + const leaseFor = (deviceId: string) => Option.fromNullishOr(leases.get(deviceId)); + const operationError = (operation: string) => (cause: unknown) => + new DeviceOperationError({ operation, reason: "request_failed", cause }); + + const screenshotLease = Effect.fn("SimlockDeviceHost.screenshot")( + function* (lease: Lease) { + const file = path.join( + lease.dir, + `screenshot-${(yield* Random.nextIntBetween(0, 0xffffff)).toString(16)}.png`, + ); + yield* agentDevice(lease, ["screenshot", file]); + return yield* fs + .readFile(file) + .pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore))); + }, + Effect.mapError(operationError("screenshot")), + ); + + const devices: DeviceHost.DeviceOperations = { + list: loadCatalog.pipe( + Effect.mapError(operationError("list")), + Effect.map((available) => ({ + devices: [ + ...available, + ...[...leases.values()].map((lease): DeviceHost.HostDevice => ({ + id: lease.leaseId, + platform: lease.platform, + name: lease.name, + version: lease.version, + booted: true, + physical: false, + })), + ], + detail: undefined, + })), + ), + boot: (device, context) => + leases.has(device.id) + ? Effect.succeed(device.id) + : Effect.acquireUseRelease( + reserveSlot, + () => openLease(device, context), + () => Effect.sync(() => void starting--), + ), + attach: () => Effect.void, + shutdown: (device) => + Option.match(leaseFor(device.id), { + onNone: () => Effect.void, + onSome: (lease) => + FiberMap.remove(keepAlive, lease.leaseId).pipe(Effect.andThen(release(lease))), + }), + screenshot: (device) => + Option.match(leaseFor(device.id), { + onNone: () => + Effect.fail(operationError("screenshot")(new Error("The Simlock lease has ended"))), + onSome: screenshotLease, + }), + agentEndpoint: (deviceId) => + Option.match(leaseFor(deviceId), { + onNone: () => Effect.fail(unavailable("This Simlock lease has ended.")), + onSome: (lease) => + Effect.succeed({ + baseUrl: agentDeviceUrl, + token: agentToken, + agentDeviceId: null, + session: lease.session, + stateDir: lease.dir, + installDirectory: lease.installDirectory, + }), + }), + endsDevices: true, + }; + + /** + * The client speaks serve-sim to every Simlock lease: its AVCC stream and its input + * socket map to the lease's own stream and input routes, which take the agent token. + */ + const upstream: NonNullable = (hubPath, search) => + Effect.sync(() => { + const stream = STREAM_PATH.exec(hubPath); + let deviceId: string | null; + try { + deviceId = stream + ? decodeURIComponent(stream[1]!) + : hubPath === INPUT_PATH + ? search.get("device") + : null; + } catch { + return null; + } + const lease = deviceId === null ? undefined : leases.get(deviceId); + if (!lease) return null; + return { + url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`, + headers: { authorization: `Bearer ${agentToken}` }, + protocols: [], + }; + }); + + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = Effect.fn( + "SimlockDeviceHost.ensureReady", + )(function* (onPhase) { + if (ready) return ready; + // A missing token or unreachable gateway does not stop readiness: listing + // and opening devices say what is wrong instead. + yield* onPhase("installing"); + cli = yield* agentDeviceCli; + const { nodePath } = cli; + ready = { + nodePath, + hub: { origin: gateway }, + devices, + upstream, + // Simlock relays only simctl, adb, and devicectl, and only to a lease's owner. + run: () => + Effect.succeed({ + stdout: "", + stderr: "Simlock devices cannot run host commands.", + code: 127, + }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return ready; + }); + + const stopAll = Effect.gen(function* () { + stops++; + yield* FiberMap.clear(keepAlive); + yield* Effect.forEach([...leases.values()], release, { discard: true, concurrency: 4 }); + }).pipe(Effect.ignore); + yield* Effect.addFinalizer(() => stopAll); + + const platformAvailability = (platform: DevicePlatform) => + loadCatalog.pipe( + Effect.ignore, + Effect.map( + (): DevicePlatformAvailability => + platforms.find((candidate) => candidate.platform === platform) ?? { + platform, + available: false, + }, + ), + ); + + const summary: Effect.Effect = Effect.sync(() => ({ + id: hostId, + label: config.label, + kind: "simlock" as const, + platforms, + hubInstalled: true, + agentDeviceInstalled: true, + })); + + return DeviceHost.DeviceHost.of({ + id: hostId, + label: config.label, + summary, + platformAvailability, + ensureReady, + ensureAgentReady: Effect.fn("SimlockDeviceHost.ensureAgentReady")(function* (onPhase) { + return { ...(yield* ensureReady(onPhase)), agentDevice: null }; + }), + current: Effect.sync(() => ready), + stopAgent: Effect.void, + stop: stopAll, + }); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 3c28dff3ad6e..1d72e89ba755 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -20,6 +20,7 @@ import { readAgentActivityPublishingActive } from "../cloud/config.ts"; import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; +import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ProcessRunner from "../processRunner.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; @@ -203,6 +204,7 @@ export const make = Effect.gen(function* () { const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; + const databaseMigration = databaseMigrationHead(serverConfig.dbPath); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, label, @@ -213,6 +215,7 @@ export const make = Effect.gen(function* () { }, serverVersion: packageJson.version, orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + ...(databaseMigration === undefined ? {} : { databaseMigration }), capabilities: { repositoryIdentity: true, connectionProbe: true, @@ -247,6 +250,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, + threadProjectFromScratch: true, threadTitleRegeneration: true, commandOutputStreaming: true, threadVisitedTracking: true, @@ -266,7 +270,7 @@ export const make = Effect.gen(function* () { ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { serverSelfUpdateProgress: true } : {}), - ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), + ...(desktopAppUpdate ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } : {}), serverBrowser: true, peerLinks: true, mcpModeLimitHeader: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 8e6a42c2271d..3c13991426f9 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1,3 +1,5 @@ +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import { SourceControlProviderKind } from "@t3tools/contracts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; @@ -69,6 +71,10 @@ type FakeChangeRequest = Omit }; interface FakeProviderScenario { + prListGate?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; /** The kind the fake host reports itself as. */ kind?: string; /** Answers to successive change request listings, whatever their head selector. */ @@ -452,6 +458,13 @@ function createFakeSourceControlProvider(scenario: FakeProviderScenario = {}): { [] ).map(toChangeRequest), ), + Effect.tap(() => + scenario.prListGate + ? Deferred.succeed(scenario.prListGate.started, undefined).pipe( + Effect.andThen(Deferred.await(scenario.prListGate.release)), + ) + : Effect.void, + ), ), createChangeRequest: (input) => record(input.cwd, { @@ -723,6 +736,108 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe( ); it.layer(layerGitManagerTest)("GitManager", (it) => { + it.effect( + "publishes local Git changes during a held PR lookup without caching its old branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-refresh-status-gh-gate-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "release"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "release"]); + yield* runGit(repoDir, ["tag", "release"]); + const oldPr = { + number: 613, + title: "Release PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/613", + baseRefName: "main", + headRefName: "release", + }; + const newPr = { ...oldPr, number: 614, headRefName: "heads/release" }; + const providerScenario: FakeProviderScenario = { + prListSequence: [oldPr, oldPr, newPr].map((pr) => [pr]), + }; + const { manager } = yield* makeManager({ providerScenario }); + const context = yield* Layer.build( + VcsStatusBroadcaster.layer.pipe( + Layer.provide( + GitWorkflowService.layer.pipe( + Layer.provide(Layer.succeed(GitManager.GitManager, manager)), + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), + Layer.provide(VcsProcess.layer), + ), + ), + ), + ), + Layer.provide( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + }), + ), + ), + ); + const broadcaster = Context.get(context, VcsStatusBroadcaster.VcsStatusBroadcaster); + const initial = yield* broadcaster.getStatus({ cwd: repoDir }); + expect(initial.refName).toBe("release"); + expect(initial.pr?.number).toBe(613); + + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: repoDir, includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated" && event.local.hasWorkingTreeChanges) { + return Deferred.succeed(published, undefined); + } + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const gate = { + started: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + providerScenario.prListGate = gate; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(NodePath.join(repoDir, "README.md"), "changed before refresh\n"); + const completed = yield* Deferred.make(); + const refresh = yield* broadcaster + .refreshStatus(repoDir) + .pipe(Effect.ensuring(Deferred.succeed(completed, undefined)), Effect.forkScoped); + yield* Deferred.await(gate.started); + const pending = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })), + ); + expect(pending._tag).toBe("snapshot"); + if (pending._tag === "snapshot") expect(pending.local.hasWorkingTreeChanges).toBe(true); + yield* Deferred.await(published); + expect(yield* Deferred.isDone(completed)).toBe(false); + + yield* runGit(repoDir, ["checkout", "-b", "heads/release"]); + yield* runGit(repoDir, ["push", "-u", "origin", "refs/heads/heads/release"]); + yield* Deferred.succeed(gate.release, undefined); + const refreshed = yield* Fiber.join(refresh); + expect(refreshed.refName).toBe("heads/release"); + expect(refreshed.hasWorkingTreeChanges).toBe(true); + expect(refreshed.pr).toBeNull(); + const snapshot = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: repoDir, includeRemote: false })), + ); + expect(snapshot._tag).toBe("snapshot"); + if (snapshot._tag === "snapshot") expect(snapshot.remote).toBeNull(); + + delete providerScenario.prListGate; + const recovered = yield* broadcaster.getStatus({ cwd: repoDir }); + expect(recovered.refName).toBe("heads/release"); + expect(recovered.pr?.number).toBe(614); + expect((yield* broadcaster.getStatus({ cwd: repoDir })).pr?.number).toBe(614); + }), + ); + it.effect("status reports the listed PR whose head is the branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2484,7 +2599,45 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("applies a V2-native thread's project settings to generated commit text", () => + it.effect("uses an explicit project's provider settings for generated commit text", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nproject\n"); + const projectId = ProjectId.make("project:git-action-settings"); + const disabledInstanceId = DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.instanceId; + let generatedModelSelection: + | TextGeneration.CommitMessageGenerationInput["modelSelection"] + | undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [disabledInstanceId]: { enabled: false } }, + }, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedModelSelection = input.modelSelection; + return Effect.succeed({ subject: "Use the project writer", body: "" }); + }, + }, + }); + + yield* manager.runStackedAction({ + actionId: "test-project-action-settings", + cwd: repoDir, + action: "commit", + projectId, + }); + + expect(generatedModelSelection?.instanceId).toBe(ProviderInstanceId.make("claudeAgent")); + }), + ); + + it.effect("prefers a V2-native thread's project settings over an explicit project", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -2571,6 +2724,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); @@ -3791,7 +3945,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { return { repoDir, manager, providerCalls, firstCommit, pullHead }; }); - it.effect.each(["switch", "create-and-switch", "feature-branch"] as const)( + it.effect.each(["switch", "create-and-switch", "feature-branch", "commit"] as const)( "local PR checkout excludes a concurrent %s from a nested project with an alternate root", (operation) => Effect.gen(function* () { @@ -3841,7 +3995,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { ...manager, runStackedAction: (input, options) => Effect.sync(() => { - events.push("feature-branch"); + events.push(input.featureBranch ? "feature-branch" : "commit"); NodeFS.writeFileSync( NodePath.join(repoDir, "second-client.txt"), "second client\n", @@ -3878,9 +4032,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }) : workflows[1]!.runStackedAction({ cwd: nestedCwd, - actionId: "second-client-feature-branch", + actionId: `second-client-${operation}`, action: "commit", - featureBranch: true, + featureBranch: operation === "feature-branch", commitMessage: "Second client commit", }), { startImmediately: true }, @@ -3893,15 +4047,16 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(events).toEqual([operation]); expect(prepared).toMatchObject({ branch: "feature/pr-safe", isOnPullRequestHead: true }); expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainCommit); - expect((yield* runGit(repoDir, ["rev-parse", "feature/pr-safe"])).stdout.trim()).toBe( - pullHead, - ); + const pullBranch = operation === "commit" ? "feature/pr-safe^" : "feature/pr-safe"; + expect((yield* runGit(repoDir, ["rev-parse", pullBranch])).stdout.trim()).toBe(pullHead); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe( operation === "switch" ? "main" - : "branch" in switched - ? switched.branch.name - : "second-client", + : operation === "commit" + ? "feature/pr-safe" + : "branch" in switched + ? switched.branch.name + : "second-client", ); if ("commit" in switched) { expect(switched.commit.status).toBe("created"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 3c0fddf8c588..83928ccdc564 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -673,19 +673,25 @@ export const make = Effect.gen(function* () { const projectSettingsFor = Effect.fnUntraced(function* (input: { readonly cwd: string; readonly threadId?: ThreadId | undefined; + readonly projectId?: ProjectId | undefined; }) { const settings = yield* serverSettingsService.getSettings; if (!hasProjectSettingsOverrides(settings)) return { settings, projectId: null as ProjectId | null }; - const projectId: ProjectId | null = yield* input.threadId !== undefined - ? threads.getThreadShell(input.threadId).pipe( - Effect.map((thread) => thread?.projectId ?? null), - Effect.orElseSucceed(() => null), - ) - : projects.findActiveByWorkspaceRoot(input.cwd).pipe( - Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), - Effect.orElseSucceed(() => null), - ); + const threadProjectId = + input.threadId === undefined + ? null + : yield* threads.getThreadShell(input.threadId).pipe( + Effect.map((thread) => thread?.projectId ?? null), + Effect.orElseSucceed(() => null), + ); + const projectId = + threadProjectId ?? + input.projectId ?? + (yield* projects.findActiveByWorkspaceRoot(input.cwd).pipe( + Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); return { settings: resolveProjectSettings(settings, projectId).settings, projectId }; }); const readThreadContext = Effect.fnUntraced( diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 832dc6c4ef62..fdc00849af2d 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -52,6 +52,7 @@ export class GitWorkflowService extends Context.Service< readonly localStatus: ( input: VcsStatusInput, ) => Effect.Effect; + readonly currentBranch: (cwd: string) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, options?: GitManager.GitRemoteStatusOptions, @@ -332,6 +333,32 @@ export const make = Effect.gen(function* () { ), ); }), + currentBranch: Effect.fn("GitWorkflowService.currentBranch")(function* (cwd) { + if (!(yield* detectGitRepositoryForStatus("GitWorkflowService.currentBranch", cwd))) { + return null; + } + const result = yield* git.execute({ + operation: "GitWorkflowService.currentBranch", + cwd, + args: ["symbolic-ref", "--quiet", "HEAD"], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (result.exitCode === 0) { + return result.stdout.trim().replace(/^refs\/heads\//, "") || null; + } + if (result.exitCode === 1) return null; + return yield* new GitCommandError({ + operation: "GitWorkflowService.currentBranch", + command: "git symbolic-ref", + cwd, + argumentCount: 3, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + detail: "Failed to read the checked-out branch.", + }); + }), remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) { return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => @@ -350,12 +377,10 @@ export const make = Effect.gen(function* () { runStackedAction: Effect.fn("GitWorkflowService.runStackedAction")(function (input, options) { return ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.flatMap((handle) => - input.featureBranch - ? withCheckoutLease( - handle.repository.rootPath, - gitManager.runStackedAction(input, options), - ) - : gitManager.runStackedAction(input, options), + withCheckoutLease( + handle.repository.rootPath, + gitManager.runStackedAction(input, options), + ), ), ); }), diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 31acd7bdb60b..1ef25744c8c5 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -411,7 +411,6 @@ it.effect("serves no render for an item outside the shared thread", () => }).pipe(Effect.provide(layerTest)), ); -/** A message from the owner, which opens a run for the command below. */ const ownerRun = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; yield* orchestrator.dispatch({ @@ -430,12 +429,17 @@ const ownerRun = Effect.gen(function* () { return runs[0]!.id; }); -const writeCommand = ( +const writeItem = ( id: string, - fields: Pick< - Extract, - "status" | "output" - >, + fields: + | Pick< + Extract, + "type" | "status" | "input" | "output" + > + | Pick< + Extract, + "type" | "status" | "fileName" | "diffStr" | "oldStr" | "newStr" + >, ) => Effect.gen(function* () { const runId = yield* ownerRun; @@ -464,8 +468,6 @@ const writeCommand = ( startedAt: now, completedAt: fields.status === "running" ? null : now, updatedAt: now, - type: "command_execution", - input: "ls /work/src", ...fields, }, }, @@ -474,17 +476,40 @@ const writeCommand = ( return TurnItemId.make(id); }); -it.effect("gives the guest a command's detail and output, as the owner's row loads them", () => +it.effect("gives the guest a command's detail and output, but no file diffs", () => Effect.gen(function* () { const { guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:command", { + const command = yield* writeItem("item:command", { + type: "command_execution", status: "completed", + input: "ls /work/src", output: "/work/src/auth.ts\n", }); const detail = (yield* guestShares.item(session, command)).item; assert.deepInclude(detail, { input: "ls /work/src", output: "/work/src/auth.ts\n" }); + const edit = yield* writeItem("item:edit", { + type: "file_change", + status: "completed", + fileName: "src/auth.ts", + diffStr: "-const token = 1;\n+const token = 2;\n", + }); + const write = yield* writeItem("item:write", { + type: "file_change", + status: "completed", + fileName: "src/config.ts", + oldStr: "", + newStr: "export const key = 1;\n", + }); + for (const [id, fileName] of [ + [edit, "src/auth.ts"], + [write, "src/config.ts"], + ] as const) { + const change = (yield* guestShares.item(session, id)).item; + assert.deepInclude(change, { type: "file_change", fileName }); + assert.doesNotHaveAnyKeys(change, ["diffStr", "oldStr", "newStr"]); + } const frames = Array.from( yield* Stream.runCollect(yield* guestShares.commandOutput(session, command)), ); @@ -508,7 +533,11 @@ it.effect("ends a running command's output once the owner stops sharing", () => Effect.gen(function* () { const { orchestrator, guestShares, credential } = yield* shareThread; const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); - const command = yield* writeCommand("item:running", { status: "running" }); + const command = yield* writeItem("item:running", { + type: "command_execution", + status: "running", + input: "ls /work/src", + }); const collecting = yield* Stream.runCollect( yield* guestShares.commandOutput(session, command), ).pipe(Effect.forkChild); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 0346068bc546..347bdc11ece5 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -57,6 +57,7 @@ import { buildBoundedThreadStreamSnapshot } from "../orchestration-v2/ThreadStre import { projectDomainEventForWire, projectTurnItemForDetail, + projectTurnItemForWire, } from "../orchestration-v2/WireProjection.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -179,7 +180,7 @@ export class GuestShareService extends Context.Service< session: string, itemId: string, ) => Effect.Effect<{ readonly path: string; readonly title: string }, GuestShareAccessError>; - /** One item of the shared thread, as the owner's expanded row loads it. */ + /** One item of the shared thread, as the owner's expanded row loads it, without file diffs. */ readonly item: ( session: string, itemId: string, @@ -658,7 +659,10 @@ const make = Effect.gen(function* () { const item = Effect.fn("GuestShareService.item")(function* (session: string, itemId: string) { const { item } = yield* threadItem(session, itemId); - return { item: projectTurnItemForDetail(item) }; + return { + item: + item.type === "file_change" ? projectTurnItemForWire(item) : projectTurnItemForDetail(item), + }; }); const commandOutput = Effect.fn("GuestShareService.commandOutput")(function* ( diff --git a/apps/server/src/home/FleetService.ts b/apps/server/src/home/FleetService.ts index 8c776cd64fdd..3dff6ba9188e 100644 --- a/apps/server/src/home/FleetService.ts +++ b/apps/server/src/home/FleetService.ts @@ -128,7 +128,9 @@ export function listThreadPage( currentThreadId: filtered.some((thread) => thread.id === actor.threadId) ? actor.threadId : null, - threads: page.map((shell) => listItemFromShell(environmentId, shell, nowMs)), + threads: page.map((shell) => + listItemFromShell({ environmentId, client: undefined }, shell, nowMs), + ), nextCursor: next < filtered.length ? next : null, total: filtered.length, }; @@ -264,7 +266,13 @@ const make = Effect.gen(function* () { const target = yield* threads .getThreadRecords(input.threadId, ["runs", "runtimeRequests", "contextTransfers"]) .pipe(Effect.mapError(threadManagementFailure)); - return (yield* readThreadPage(environmentId, threads, target, shell, input)).result; + return (yield* readThreadPage( + { environmentId, client: undefined }, + threads, + target, + shell, + input, + )).result; }), "threads.launch": Effect.fn("FleetService.threads.launch")(function* (input, actor) { diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 0319bbb55c03..5f7df20c738c 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -13,7 +13,10 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; +import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; +import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; import { @@ -139,6 +142,66 @@ describe("browser API CORS", () => { }); }); +describe("untraced requests", () => { + it.effect("keeps signed asset and upload URLs out of HTTP server spans", () => + Effect.gen(function* () { + const serverSpans: Array = []; + const layerTracer = Layer.succeed( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + if (options.kind === "server") serverSpans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ); + const layerRoutes = Layer.effectDiscard( + Effect.gen(function* () { + const router = yield* HttpRouter.HttpRouter; + yield* router.add("GET", `${ASSET_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); + yield* router.add( + "POST", + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, + HttpServerResponse.empty(), + ); + yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); + }), + ); + const services = yield* Layer.build( + HttpRouter.serve(layerRoutes, { disableListenLog: true, disableLogger: true }).pipe( + ServerHttp.withUntracedRequests, + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provide(layerTracer), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + + // Each URL is a bearer credential: url.full and url.path would carry it. + for (const request of [ + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), + HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), + ]) { + expect((yield* client.execute(request)).status).toBe(204); + } + expect(serverSpans).toEqual([]); + + expect((yield* client.execute(HttpClientRequest.get("/api/environment"))).status).toBe(204); + expect(serverSpans).toEqual(["GET"]); + + for (const path of [ + `${ASSET_ROUTE_PREFIX}-other/file`, + `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}-other/token`, + ]) { + expect((yield* client.execute(HttpClientRequest.get(path))).status).toBe(404); + } + expect(serverSpans).toEqual(["GET", "GET", "GET"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 941f5f7db078..1129c37ed186 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -391,8 +391,13 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_P const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); - // Webhook URLs carry their secret token in the path, so they never reach a trace. - return UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`); + // Signed URLs carry bearer tokens in the path, so they never reach a trace. + return ( + UNTRACED_REQUEST_PATHS.has(path) || + path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || + path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + ); }); export const withUntracedRequests = Layer.provide(layerUntracedRequests); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 26795c51696b..5ce9669bc4d8 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -12,6 +12,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -44,6 +45,8 @@ export interface McpClientCaller { * another environment passing on the limits of the agent it calls for. */ readonly narrowedTo?: ClientModes | undefined; + /** The origin an outside client reached; its thread links open the web app there. */ + readonly webOrigin?: string | undefined; } /** @@ -105,6 +108,20 @@ export interface McpInvocationScope { readonly linkedEnvironments?: boolean; } +/** + * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so + * they get a web URL on the origin they reached instead. + */ +export const threadLinkFor = ( + scope: Pick, + threadId: ThreadId, +): string => { + const origin = scope.client?.webOrigin; + return origin === undefined + ? formatThreadLink({ environmentId: scope.environmentId, threadId }) + : `${origin}/${encodeURIComponent(scope.environmentId)}/${encodeURIComponent(threadId)}`; +}; + export class McpInvocationContext extends Context.Service< McpInvocationContext, McpInvocationScope diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 611159a113c9..4f7f678fec1c 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1,6 +1,5 @@ import { CommandId, - type EnvironmentId, type RunId, isProviderAvailable, MessageId, @@ -109,6 +108,7 @@ import { type McpInvocationScope, type McpThreadInvocationScope, requireThreadScope, + threadLinkFor, } from "./McpInvocationContext.ts"; import * as Metrics from "../observability/Metrics.ts"; import { @@ -805,8 +805,11 @@ function threadPullRequests( })); } +/** What a thread link needs from the caller: Home's fleet reads have no MCP client. */ +type ThreadLinkScope = Pick; + export function listItemFromShell( - environmentId: EnvironmentId, + scope: ThreadLinkScope, shell: OrchestrationV2ThreadShell, nowMs: number, ): OrchestratorMcpThreadListItem { @@ -814,7 +817,7 @@ export function listItemFromShell( threadId: shell.id, projectId: shell.projectId, title: shell.title, - link: formatThreadLink({ environmentId, threadId: shell.id }), + link: threadLinkFor(scope, shell.id), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -836,7 +839,7 @@ export function listItemFromShell( } function threadDetail( - environmentId: EnvironmentId, + scope: ThreadLinkScope, projection: Pick, itemCount: number, shell: OrchestrationV2ThreadShell, @@ -848,7 +851,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, - link: formatThreadLink({ environmentId, threadId: projection.thread.id }), + link: threadLinkFor(scope, projection.thread.id), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -1020,7 +1023,7 @@ function timelineItem(input: { * acknowledgement on top. */ export const readThreadPage = Effect.fn("OrchestratorMcp.readThreadPage")(function* ( - environmentId: EnvironmentId, + scope: ThreadLinkScope, threadManagement: ThreadManagementService.ThreadManagementService["Service"], target: Pick, shell: OrchestrationV2ThreadShell, @@ -1058,7 +1061,7 @@ export const readThreadPage = Effect.fn("OrchestratorMcp.readThreadPage")(functi ); const messagesByThreadId = new Map(sourceMessages); const result = { - thread: threadDetail(environmentId, target, timeline.totalItems, shell, nowMs), + thread: threadDetail(scope, target, timeline.totalItems, shell, nowMs), recentRuns: target.runs .toSorted((left, right) => right.ordinal - left.ordinal) .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT) @@ -2888,20 +2891,14 @@ const make = Effect.gen(function* () { return { projectId, currentThreadId: parent?.thread.id ?? null, - threads: page.map((shell) => listItemFromShell(scope.environmentId, shell, nowMs)), + threads: page.map((shell) => listItemFromShell(scope, shell, nowMs)), nextCursor, total: filtered.length, } satisfies OrchestratorMcpThreadListResult; }), readThread: Effect.fn("OrchestratorMcpService.readThread")(function* (scope, input) { const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId); - const { result, page } = yield* readThreadPage( - scope.environmentId, - threadManagement, - target, - shell, - input, - ); + const { result, page } = yield* readThreadPage(scope, threadManagement, target, shell, input); const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS; const returnedRunIds = new Set( page.filter((row) => row.sourceThreadId === target.thread.id).map((row) => row.item.runId), @@ -2989,7 +2986,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, - link: formatThreadLink({ environmentId: scope.environmentId, threadId: input.threadId }), + link: threadLinkFor(scope, input.threadId), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined ? {} : { taskId: result.run.delegatedTaskId }), diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 2922b7872400..9882c99e5302 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -183,6 +183,7 @@ const layerUnusedScheduledTaskStub = Layer.succeed( setEnabled: () => Effect.die("ScheduledTaskService.setEnabled is unused in this test"), delete: () => Effect.die("ScheduledTaskService.delete is unused in this test"), deleteForThread: () => Effect.die("unused in this test"), + moveThreadTasks: () => Effect.die("unused in this test"), runNow: () => Effect.die("ScheduledTaskService.runNow is unused in this test"), rotateWebhookToken: () => Effect.die("unused in this test"), listWebhookDeliveries: () => Effect.die("unused in this test"), diff --git a/apps/server/src/mcp/cloudReach.test.ts b/apps/server/src/mcp/cloudReach.test.ts index b8f08677a6c8..445946963e2d 100644 --- a/apps/server/src/mcp/cloudReach.test.ts +++ b/apps/server/src/mcp/cloudReach.test.ts @@ -32,6 +32,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { routeHome, runAsHome } from "./homeRouting.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as HomeHandlers from "./toolkits/home/handlers.ts"; @@ -181,6 +182,7 @@ it.effect("hands a cloud chat's launch elsewhere to its host, which picks the th ServerSettings.layerTest(), NodeCrypto.layer, Layer.mock(ThreadLaunch.ThreadLaunchService)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), Layer.mock(Project.ProjectService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 4036f083ca91..6812754af199 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -30,16 +30,28 @@ export function agentDeviceQuickStart( device: DeviceSummary, targetArgs = agentDeviceTargetArgs(device), command = "agent-device", - cloud = false, + host?: + | { readonly kind: "eas" } + | { readonly kind: "simlock"; readonly installDirectory?: string }, ): string { const executable = shellWord(command); const target = targetArgs.map(shellWord).join(" "); const run = (args: string) => [executable, args, target].filter(Boolean).join(" "); - const platformNotes = cloud - ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." - : device.platform === "ios" - ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." - : "The Android snapshot helper installs itself on first use."; + const platformNotes = + host?.kind === "simlock" + ? [ + "This device is leased from a Simlock pool (alpha in T3 Code). The lease returns it to the pool, wiped, when it ends, so call device_close with shutdown: true when you are done. It starts with no apps installed.", + host.installDirectory + ? `agent-device accepts install for this lease only when run from ${host.installDirectory}.` + : "", + ] + .filter(Boolean) + .join(" ") + : host?.kind === "eas" + ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." + : device.platform === "ios" + ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." + : "The Android snapshot helper installs itself on first use."; return [ `The user is watching ${device.name} (${device.version}) in the Device panel.`, target === "" @@ -222,9 +234,11 @@ const handlers = { deviceId: session.deviceId, }; const agent = yield* devices.agentTarget(opened); - const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); + const hostKind = after.hosts.find((host) => host.id === device.hostId)?.kind; const targetArgs = [ - ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), + ...(agent.agentDeviceId === null + ? [] + : agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId })), ...agent.args, ]; const launcher = yield* devices.agentLauncher({ ...opened, targetArgs }); @@ -238,7 +252,19 @@ const handlers = { return { device, agentDevice: { command, targetArgs, session: agent.session }, - quickStart: agentDeviceQuickStart(device, [], command, cloud), + quickStart: agentDeviceQuickStart( + device, + [], + command, + hostKind === "simlock" + ? { + kind: "simlock", + ...(agent.installDirectory ? { installDirectory: agent.installDirectory } : {}), + } + : hostKind === "eas" + ? { kind: "eas" } + : undefined, + ), }; }).pipe(Effect.mapError(toolError)), ), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index a922c6484b87..6a41cd6912cb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,3 +1,4 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, @@ -7,6 +8,8 @@ import { ProviderInstanceId, ThreadId, type OrchestrationV2ThreadShell, + ProviderDriverKind, + type ServerProvider, type Project as ProjectRecord, type ModelSelection, type RuntimeMode, @@ -61,6 +64,7 @@ it.effect("makes Home name a project and watches the thread before launching it" let home = { threadId: homeThreadId, watchAll: false, watches: [] } as HomeSettings; const dependencies = Layer.mergeAll( ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), NodeCrypto.layer, Layer.mock(HomeService.HomeService)({ available: true, @@ -153,11 +157,14 @@ it.effect("makes Home name a project and watches the thread before launching it" const clientLaunchHarness = (input: { readonly runtimeModeCeiling: RuntimeMode; readonly defaultRuntimeMode?: RuntimeMode; + readonly fallbackModel?: ModelSelection; + readonly providers?: readonly ServerProvider[]; /** Where Settings saves a project's model; the legacy project field stays null. */ readonly overrideModel?: ModelSelection; readonly launched: Array; readonly workspaceRoot?: string; readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; + readonly webOrigin?: string; }) => { const projectId = ProjectId.make("project:client-target"); const modelSelection = { @@ -169,7 +176,11 @@ const clientLaunchHarness = (input: { model: input.overrideModel === undefined ? modelSelection : null, }; const layerDependencies = Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(input.providers ?? []), + }), ServerSettings.layerTest({ + fallbackModelSelection: input.fallbackModel ?? null, ...(input.defaultRuntimeMode === undefined ? {} : { defaultRuntimeMode: input.defaultRuntimeMode }), @@ -192,6 +203,7 @@ const clientLaunchHarness = (input: { sessionId: "session-1", label: "Claude Code", access: input.runtimeModeCeiling, + webOrigin: input.webOrigin, }, issuedAt: 0, capabilities: new Set(["orchestration" as const]), @@ -272,15 +284,17 @@ it.effect( readonly runtimeMode?: RuntimeMode; readonly targeted?: false; readonly overrideModel?: ModelSelection; + readonly webOrigin?: string; }, ) { - const { targeted, overrideModel, ...input } = params; + const { targeted, overrideModel, webOrigin, ...input } = params; const launched: Array = []; const { projectId, modelSelection, dependencies } = clientLaunchHarness({ runtimeModeCeiling, defaultRuntimeMode, launched, ...(overrideModel === undefined ? {} : { overrideModel }), + ...(webOrigin === undefined ? {} : { webOrigin }), }); const toolkit = yield* ProjectToolkit.pipe( Effect.provide( @@ -307,8 +321,14 @@ it.effect( expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); - const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { title: "Fix" }); + const aboveCeiling = yield* launchWith("auto-accept-edits", "full-access", { + title: "Fix", + webOrigin: "https://box.example.ts.net", + }); expect(aboveCeiling.launched[0]?.runtimeMode).toBe("auto-accept-edits"); + expect(aboveCeiling.result).toMatchObject({ + link: `https://box.example.ts.net/environment/${encodeURIComponent(aboveCeiling.launched[0]?.threadId ?? "")}`, + }); const requested = yield* launchWith("full-access", "approval-required", { title: "Fix", @@ -580,3 +600,52 @@ it.effect("a retried worktree launch replays after its base ref is gone", () => ]); }), ); + +it.effect("a client launches with the fallback for an exhausted default", () => + Effect.gen(function* () { + const launched: ThreadLaunch.ThreadLaunchInput[] = []; + const fallbackModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(driver === "claudeAgent" + ? { + usageLimits: { + checkedAt: "2026-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched, + fallbackModel, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_thread_launch", { + projectId, + title: "Fallback", + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ + modelSelection: fallbackModel, + }); + expect(launched[0]?.modelSelection).toEqual(fallbackModel); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index df7fee45f448..7a05620c4496 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -30,8 +30,10 @@ import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.t import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as DateTime from "effect/DateTime"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -272,7 +274,18 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { input.modelSelection ?? launchedModel ?? caller?.modelSelection ?? - projectDefaults?.defaultModelSelection ?? + (projectDefaults + ? resolveNewThreadModelSelection({ + defaultSelection: projectDefaults.defaultModelSelection, + fallbackSelection: projectDefaults.fallbackModelSelection, + providers: yield* ProviderRegistry.ProviderRegistry.pipe( + Effect.flatMap((registry) => registry.getProviders), + ), + settings: projectDefaults, + projectId, + now: DateTime.toEpochMillis(yield* DateTime.now), + }).selection + : null) ?? undefined; if (modelSelection === undefined) return yield* new OrchestratorMcpFailure({ @@ -333,7 +346,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, - link: formatThreadLink({ environmentId: scope.environmentId, threadId: thread.id }), + link: McpInvocationContext.threadLinkFor(scope, thread.id), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 0d1fcc176002..ea748d4b7afb 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -22,6 +22,7 @@ import { import * as FileSystem from "effect/FileSystem"; import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ServerConfig from "../../../config.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts"; import * as Crypto from "effect/Crypto"; @@ -157,6 +158,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index bcf0b2c255cf..7a63779009bc 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -11,7 +11,6 @@ import { import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { modelSelectionCommandType } from "@t3tools/shared/model"; -import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -119,11 +118,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { const { task, threadId } = yield* scheduler .runNow({ id: input.taskId }) .pipe(Effect.mapError(unavailable)); - const { environmentId } = yield* McpInvocationContext.McpInvocationContext; + const scope = yield* McpInvocationContext.McpInvocationContext; return { taskId: task.id, threadId, - ...(threadId === null ? {} : { link: formatThreadLink({ environmentId, threadId }) }), + ...(threadId === null + ? {} + : { + link: McpInvocationContext.threadLinkFor(scope, threadId), + }), lastRunStatus: task.lastRunStatus, runCount: task.runCount, nextRunAt: task.nextRunAt, @@ -136,7 +139,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { // Home searches every project unless it names one. const projectId = input.projectId ?? ((yield* callerIsHome()) ? undefined : caller?.projectId); - const { environmentId } = yield* McpInvocationContext.McpInvocationContext; + const scope = yield* McpInvocationContext.McpInvocationContext; const threadSearch = yield* ThreadSearch.ThreadSearch; const { matches } = yield* threadSearch .searchThreads({ ...input, projectId }) @@ -144,7 +147,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { return { matches: matches.map((match) => ({ ...match, - link: formatThreadLink({ environmentId, threadId: match.threadId }), + link: McpInvocationContext.threadLinkFor(scope, match.threadId), })), }; }), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2730fc50ade3..982a17670c0f 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -150,6 +150,7 @@ const RPC_AGGREGATES = { [WS_METHODS.projectsWriteFile]: "workspace", [WS_METHODS.projectsEnsureScratch]: "orchestration", [WS_METHODS.projectsCreateNew]: "orchestration", + [WS_METHODS.projectsCreateFromThread]: "orchestration", [WS_METHODS.shellOpenInEditor]: "workspace", [WS_METHODS.filesystemBrowse]: "workspace", [WS_METHODS.filesystemGetMetadata]: "workspace", @@ -203,6 +204,7 @@ const RPC_AGGREGATES = { [WS_METHODS.deviceConfigure]: "device", [WS_METHODS.deviceTestHost]: "device", [WS_METHODS.deviceEasAccount]: "device", + [WS_METHODS.deviceSimlockIdentity]: "device", [WS_METHODS.deviceList]: "device", [WS_METHODS.deviceOpen]: "device", [WS_METHODS.deviceClose]: "device", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index f05ace7ff750..8a68e92b8916 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -3115,6 +3115,155 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each([ + { + label: "grants an ID-only execute approval its tracked command", + kind: "execute", + granted: true, + }, + { + label: "does not grant execution to an ID-only approval of another tool", + kind: "other", + granted: false, + }, + ] as const)("$label", ({ kind, granted }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let requestPermission: Parameters[0] | undefined; + let createTerminal: Parameters[0] | undefined; + let runtimeInput: AcpAdapterV2RuntimeInput | undefined; + const makeRuntime = makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_EMIT_TRACKED_TOOL_CALL_KIND_THEN_HANG: kind }, + wrapRuntime: (runtime) => ({ + ...runtime, + handleRequestPermission: (handler) => + Effect.sync(() => { + requestPermission = handler; + }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))), + handleCreateTerminal: (handler) => + Effect.sync(() => { + createTerminal = handler; + }).pipe(Effect.andThen(runtime.handleCreateTerminal(handler))), + }), + }); + const instanceId = ProviderInstanceId.make("acp-test-id-only-permission"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + interruptPromptOnCancel: true, + makeRuntime: (input) => + Effect.sync(() => { + runtimeInput = input; + }).pipe(Effect.andThen(makeRuntime(input))), + }, + selfInvocation, + clientTerminals: {}, + }); + const threadId = ThreadId.make("thread-acp-id-only-permission"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-id-only-permission"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const turnFiber = yield* runtime + .startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ) + .pipe(Effect.forkScoped); + if (requestPermission === undefined || createTerminal === undefined) { + return yield* Effect.die("ACP runtime must register permission and terminal handlers"); + } + yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "tracked-tool-call", + ), + Stream.runHead, + ); + + const permissionFiber = yield* requestPermission( + { + sessionId: "mock-session-1", + toolCall: { toolCallId: "tracked-tool-call" }, + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + }, + { requestId: "id-only-permission-request", method: "session/request_permission" }, + ).pipe(Effect.forkScoped); + const approvalEvent = Option.getOrThrow( + yield* runtime.events.pipe( + Stream.filter( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runHead, + ), + ); + if ( + approvalEvent.type !== "turn_item.updated" || + approvalEvent.turnItem.type !== "approval_request" || + approvalEvent.turnItem.providerTurnId === null + ) { + return yield* Effect.die("Expected an ACP approval request"); + } + const approval = approvalEvent.turnItem; + const providerTurnId = approvalEvent.turnItem.providerTurnId; + const responseFiber = yield* runtime + .respondToRuntimeRequest({ requestId: approval.requestId, decision: "accept" }) + .pipe(Effect.forkScoped); + assert.equal((yield* Fiber.join(permissionFiber)).outcome.outcome, "selected"); + if (runtimeInput?.onOutgoingResponse === undefined) { + return yield* Effect.die("ACP runtime must expose native response acknowledgements"); + } + yield* runtimeInput.onOutgoingResponse("id-only-permission-request"); + yield* Fiber.join(responseFiber); + + const terminalExit = yield* createTerminal( + { + sessionId: "mock-session-1", + command: process.execPath, + args: ["-e", ""], + }, + { requestId: "id-only-permission-terminal", method: "terminal/create" }, + ).pipe(Effect.exit); + assert.deepEqual( + { prompt: approval.prompt, terminalCreated: Exit.isSuccess(terminalExit) }, + { prompt: "git status", terminalCreated: granted }, + ); + + yield* runtime.interruptTurn({ providerThread, providerTurnId }); + yield* Fiber.join(turnFiber); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("replaces the ACP session and clears conversation state on rollback", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fab0020b7e03..e086f8e2d1a7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1007,6 +1007,42 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + // regression: #26695 cancelling a Copilot prompt kills its background agents + it.effect("never steers Copilot by cancelling its prompt", () => + Effect.gen(function* () { + const steering = Effect.fn("steering")(function* (config: Record) { + const instanceId = ProviderInstanceId.make("acp-registry-steering"); + const adapter = yield* makeAcpRegistryAdapterV2({ + selfInvocation: yield* resolveSelfInvocation(), + instanceId, + settings: yield* decodeAcpRegistryAdapterSettings(config), + environment: {}, + }).pipe(Effect.provide(layerInjectedRuntimeCatalog)); + const { turns } = yield* adapter.getCapabilities(); + return { + supportsInterrupt: turns.supportsInterrupt, + supportsActiveSteering: turns.supportsActiveSteering, + supportsSteeringByInterruptRestart: turns.supportsSteeringByInterruptRestart, + }; + }); + const copilot = { + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + }; + + assert.deepEqual(yield* steering({ agentId: "github-copilot-cli" }), copilot); + assert.deepEqual( + yield* steering({ source: "local", commandPath: "C:\\tools\\copilot.cmd" }), + copilot, + ); + assert.deepEqual(yield* steering({ agentId: "gemini" }), { + ...copilot, + supportsSteeringByInterruptRestart: true, + }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("settles Claude's accepted foreground reply while retaining child output", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 4876465a960d..0f5670f1d2de 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/process"; import type * as EffectAcpSchema from "effect-acp/compat"; +import * as EffectAcpErrors from "effect-acp/errors"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -32,6 +33,7 @@ import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSession import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { normalizeAntigravitySessionUpdate } from "../../provider/acp/AntigravityProtocol.ts"; +import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../../provider/antigravityAuthSupport.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { @@ -126,8 +128,65 @@ const layerSession = Layer.mergeAll( TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), ); +describe("AntigravityAdapterV2 session open", () => { + it.effect.each([ + { + name: "browser sign-in", + cause: new EffectAcpErrors.AcpTransportError({ + detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, + cause: undefined, + }), + requiresSignIn: true, + }, + { + name: "ACP authentication required", + cause: EffectAcpErrors.AcpRequestError.authRequired(), + requiresSignIn: true, + }, + { + name: "transport failure", + cause: new EffectAcpErrors.AcpTransportError({ + detail: "Connection closed", + cause: undefined, + }), + requiresSignIn: undefined, + }, + ] as const)("classifies $name during session open", ({ cause, requiresSignIn }) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("antigravity-sign-in-test"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: () => Effect.fail(cause), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const threadId = ThreadId.make("thread-antigravity-sign-in"); + const error = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-sign-in"), + modelSelection: { instanceId, model: "gemini-test-low" }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }), + }) + .pipe(Effect.flip); + + assert.equal(error._tag, "ProviderAdapterOpenSessionError"); + assert.equal( + error._tag === "ProviderAdapterOpenSessionError" && error.requiresSignIn, + requiresSignIn, + ); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); + describe("AntigravityAdapterV2 client file system", () => { - it.effect("confines agent file requests to the workspace under full access", () => + it.effect("serves confined reads and leaves edits to the agent's diff tools", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -140,6 +199,8 @@ describe("AntigravityAdapterV2 client file system", () => { // effect-acp keeps the last handler registered per method; so does this. let readTextFile: Parameters[0] | undefined; let writeTextFile: Parameters[0] | undefined; + const logs = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-antigravity-log-" }); + const requestLog = path.join(logs, "requests.ndjson"); const crypto = yield* Crypto.Crypto; const instanceId = ProviderInstanceId.make("antigravity-containment-test"); const adapter = yield* makeAntigravityAdapterV2({ @@ -153,7 +214,7 @@ describe("AntigravityAdapterV2 client file system", () => { command: process.execPath, args: [mockAgentPath], cwd: input.cwd, - env: { T3_ACP_ANTIGRAVITY: "1" }, + env: { T3_ACP_ANTIGRAVITY: "1", T3_ACP_REQUEST_LOG_PATH: requestLog }, }, }).pipe( Effect.provideService(Crypto.Crypto, crypto), @@ -198,17 +259,31 @@ describe("AntigravityAdapterV2 client file system", () => { runtimePolicy, }); yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); - if (readTextFile === undefined || writeTextFile === undefined) { - return yield* Effect.die("Antigravity sessions must serve client file requests"); + const initialize = (yield* fileSystem.readFileString(requestLog)) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + method?: string; + params?: { clientCapabilities?: { fs?: unknown }; clientInfo?: { name?: string } }; + }, + ) + .find((request) => request.method === "initialize"); + assert.deepEqual(initialize?.params?.clientCapabilities?.fs, { + readTextFile: true, + writeTextFile: false, + }); + assert.equal(initialize?.params?.clientInfo?.name, "zed"); + assert.isUndefined(writeTextFile); + if (readTextFile === undefined) { + return yield* Effect.die("Antigravity sessions must serve client file reads"); } const context = (method: string) => ({ requestId: `test-${method}`, method }); const insidePath = path.join(workspace, "src", "inside.ts"); - yield* writeTextFile( - { sessionId: "mock-session-1", path: insidePath, content: "inside" }, - context("fs/write_text_file"), - ); - assert.equal(yield* fileSystem.readFileString(insidePath), "inside"); + yield* fileSystem.makeDirectory(path.dirname(insidePath), { recursive: true }); + yield* fileSystem.writeFileString(insidePath, "inside"); const pasted = yield* readTextFile( { sessionId: "mock-session-1", path: attachment }, context("fs/read_text_file"), @@ -220,13 +295,6 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(outsideRead)); - const outsideWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: path.join(outside, "planted.txt"), content: "x" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(outsideWrite)); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt"))); - const linkPath = path.join(workspace, "linked-secret.txt"); yield* fileSystem.symlink(outsideFile, linkPath); const linkedRead = yield* readTextFile( @@ -234,45 +302,6 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(linkedRead), "a read through an escaping symlink is denied"); - const linkedWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: linkPath, content: "overwritten" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied"); - assert.equal(yield* fileSystem.readFileString(outsideFile), "secret"); - const plantedTarget = path.join(outside, "created-through-link.txt"); - const danglingLink = path.join(workspace, "dangling.txt"); - yield* fileSystem.symlink(plantedTarget, danglingLink); - const danglingWrite = yield* writeTextFile( - { sessionId: "mock-session-1", path: danglingLink, content: "planted" }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied"); - assert.isFalse(yield* fileSystem.exists(plantedTarget)); - yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir")); - const linkedDirWrite = yield* writeTextFile( - { - sessionId: "mock-session-1", - path: path.join(workspace, "linked-dir", "new.txt"), - content: "planted", - }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link"); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt"))); - const nestedLinkedDirWrite = yield* writeTextFile( - { - sessionId: "mock-session-1", - path: path.join(workspace, "linked-dir", "a", "b", "file.txt"), - content: "planted", - }, - context("fs/write_text_file"), - ).pipe(Effect.exit); - assert.isTrue( - Exit.isFailure(nestedLinkedDirWrite), - "a write to missing directories under an escaping directory link", - ); - assert.isFalse(yield* fileSystem.exists(path.join(outside, "a"))); const insideLink = path.join(workspace, "inside-link.ts"); yield* fileSystem.symlink(insidePath, insideLink); const viaInsideLink = yield* readTextFile( @@ -737,3 +766,212 @@ describe("AntigravityAdapterV2 html_render pages", () => { }).pipe(Effect.provide(layerSession), Effect.scoped), ); }); + +describe("AntigravityAdapterV2 subagent reads", () => { + it.effect("completes a read T3 served when the agent later fails it with a step error", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-reads-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-reads-outside-", + }); + const served = path.join(workspace, "served.ts"); + yield* fileSystem.writeFileString(served, "export {};"); + const denied = path.join(outside, "secret.txt"); + yield* fileSystem.writeFileString(denied, "secret"); + const unseen = path.join(workspace, "unseen.ts"); + const retried = path.join(workspace, "retried.ts"); + const stepError = 'Model produced invalid output. ("model output error")'; + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdate: Parameters[0] | undefined; + let readTextFile: Parameters[0] | undefined; + const instanceId = ProviderInstanceId.make("antigravity-subagent-reads-test"); + const threadId = ThreadId.make("thread-antigravity-subagent-reads"); + const adapter = yield* makeAntigravityAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { T3_ACP_ANTIGRAVITY: "1" }, + }, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((runtime): RuntimeService => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdate = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + handleReadTextFile: (handler) => + Effect.sync(() => { + readTextFile = handler; + }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))), + // agy_acp_server announces a subagent's client_view_file on the parent session, + // never completes it, then fails every open call with the next step error. + prompt: () => + Effect.gen(function* () { + if (sessionUpdate === undefined || readTextFile === undefined) { + return yield* Effect.die("the adapter must handle updates and reads"); + } + const send = sessionUpdate; + const handleRead = readTextFile; + const update = (update: EffectAcpSchema.SessionUpdate) => + send( + normalizeAntigravitySessionUpdate({ sessionId: "mock-session-1", update }), + ); + // The agent's own view_file never reaches T3, so it must not take a served read. + const reads = [ + ["call_native", served, "Running view_file"], + ["call_served", served, "Running client_view_file"], + ["call_missing", retried, "Running client_view_file"], + ["call_retried", retried, "Running client_view_file"], + ["call_denied", denied, "Running client_view_file"], + ["call_unseen", unseen, "Running client_view_file"], + ] as const; + for (const [toolCallId, target, title] of reads) { + yield* update({ + sessionUpdate: "tool_call", + toolCallId, + title, + kind: "read", + status: "in_progress", + rawInput: { absolute_path: target }, + locations: [{ path: target }], + }); + } + const serve = (target: string) => + handleRead( + { sessionId: "subagent-conversation", path: target }, + { requestId: `read-${target}`, method: "fs/read_text_file" }, + ).pipe(Effect.exit); + yield* serve(served); + yield* serve(retried); + yield* fileSystem.writeFileString(retried, "export {};").pipe(Effect.orDie); + yield* serve(retried); + yield* serve(denied); + for (const [toolCallId] of reads) { + yield* update({ + sessionUpdate: "tool_call_update", + toolCallId, + status: "failed", + rawOutput: stepError, + }); + } + return { stopReason: "end_turn" as const }; + }), + })), + ), + withProcess: (_stop, task) => task, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + defaultModel: Effect.succeed(undefined), + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const modelSelection = { instanceId, model: "gemini-test-low" } as const; + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-subagent-reads"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* session.startTurn({ + appThread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project-antigravity-subagent-reads"), + title: "Antigravity subagent reads", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: workspace, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId: RunId.make("run-antigravity-subagent-reads"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-antigravity-subagent-reads"), + rootNodeId: NodeId.make("node-antigravity-subagent-reads"), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-antigravity-subagent-reads"), + text: "review the codebase", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + const events = Array.from( + yield* session.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const read = (nativeId: string) => { + const event = events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId, + ); + return event?.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? { status: event.turnItem.status, output: event.turnItem.output } + : undefined; + }; + + const firstEvent = (nativeId: string, status: string) => + events.findIndex( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId && + event.turnItem.status === status, + ); + // T3 completes a served read itself instead of leaving it running until the flush. + assert.isAbove(firstEvent("call_served", "completed"), -1); + assert.isBelow(firstEvent("call_served", "completed"), firstEvent("call_native", "failed")); + const failed = { status: "failed" as const, output: stepError }; + assert.deepEqual(read("call_served"), { status: "completed", output: undefined }); + assert.deepEqual(read("call_retried"), { status: "completed", output: undefined }); + assert.deepEqual(read("call_native"), failed); + assert.deepEqual(read("call_missing"), failed); + assert.deepEqual(read("call_denied"), failed); + assert.deepEqual(read("call_unseen"), failed); + }).pipe(Effect.provide(layerSession), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 2c41e007edf6..9ccaca83b542 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -23,18 +24,17 @@ import { buildAntigravityPrompt, type AntigravityAcpRuntimeInput, } from "../../provider/acp/AntigravityAcpSupport.ts"; -import { - readAntigravityClientTextFile, - writeAntigravityClientTextFile, -} from "../../provider/acp/AntigravityClientFiles.ts"; +import { readAntigravityClientTextFile } from "../../provider/acp/AntigravityClientFiles.ts"; import { antigravityApprovalOptions, antigravitySubagentOutput, classifyAntigravitySubagentToolCall, extractAntigravityUserInputQuestion, + makeAntigravityReadRepair, makeAntigravityUserInputResponse, normalizeAntigravityToolCall, } from "../../provider/acp/AntigravityProtocol.ts"; +import { isAntigravitySignInRequiredError } from "../../provider/antigravityAuthSupport.ts"; import { AcpProviderCapabilitiesV2, makeAcpAdapterV2, @@ -82,7 +82,7 @@ export interface AntigravityAdapterV2Options { readonly continuationRequests?: Parameters[0]["continuationRequests"]; } -/** ACP 1.1.1 reports `start_subagent` tools without child session ids. */ +/** Native subagent tools report no child session ids. */ const extractAntigravitySubagentUpdate: NonNullable = ( toolCall, ) => { @@ -126,13 +126,47 @@ export function makeAntigravityAcpAdapterFlavor( Scope.close(scope, Exit.void), options.makeRuntime({ ...input, - clientFileSystem: true, + clientFileReads: true, additionalDirectories: [options.host.paths.attachmentsDir], }), ) .pipe(Effect.provideService(Scope.Scope, scope)); + // Repair keeps state across updates, so each runtime must have one session-update handler. + const reads = makeAntigravityReadRepair(); + // Served reads are reported from the read handler, so they take turns with agent updates. + const updates = yield* Semaphore.make(1); + let projectUpdate: Parameters[0] | undefined; return { ...runtime, + handleReadTextFile: (handler) => + runtime.handleReadTextFile((request, context) => + handler(request, context).pipe( + Effect.onExit((exit) => { + const served = reads.attempted(request.path, Exit.isSuccess(exit)); + if (served === undefined || projectUpdate === undefined) return Effect.void; + return updates + .withPermit( + projectUpdate({ + sessionId: served.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: served.toolCallId, + status: "completed", + }, + }), + ) + .pipe(Effect.ignore); + }), + ), + ), + prompt: (payload, promptOptions) => + Effect.sync(reads.reset).pipe(Effect.andThen(runtime.prompt(payload, promptOptions))), + handleSessionUpdate: (handler) => { + projectUpdate = handler; + return runtime.handleSessionUpdate((notification) => + updates.withPermit(handler(reads.repair(notification))), + ); + }, start: () => runtime .start() @@ -141,7 +175,7 @@ export function makeAntigravityAcpAdapterFlavor( (started) => options.onSessionStarted?.(started, input.cwd) ?? Effect.void, ), ), - }; + } satisfies typeof runtime; }, Effect.provideService(Crypto.Crypto, options.crypto), Effect.mapError((cause): EffectAcpErrors.AcpError => @@ -191,13 +225,6 @@ export function makeAntigravityAcpAdapterFlavor( allowedRoots: antigravityClientFileRoots(cwd), request, }), - writeTextFile: (request, cwd) => - writeAntigravityClientTextFile({ - fileSystem: options.fileSystem, - path: options.path, - allowedRoots: antigravityClientFileRoots(cwd), - request, - }), }, approvalOptions: antigravityApprovalOptions, extractPermissionQuestion: (request) => { @@ -212,6 +239,7 @@ export function makeAntigravityAcpAdapterFlavor( extractSubagentUpdate: extractAntigravitySubagentUpdate, supportsImagePrompts: true, supportsCompaction: true, + requiresSignIn: isAntigravitySignInRequiredError, }; } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d63feec6cfa9..1ced23604b33 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -236,6 +236,21 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { + it.each([ + "-- --thinking-display omitted", + "--thinking-display summarized -- --thinking-display omitted", + ])("does not treat operands after -- as SDK flags: %s", (launchArgs) => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "thinking-thread", + resume: false, + cwd: "/workspace", + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + }); + assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); + assert.equal(options.extraArgs?.["thinking-display"], "summarized"); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], @@ -254,6 +269,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.isUndefined(options.extraArgs?.["dangerously-skip-permissions"]); }); + it("passes every --add-dir launch directory to Claude", () => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-add-dir", + resume: false, + cwd: "/workspace", + attachmentsDir: "/attachments", + settings: { + ...AUTO_COMPACT_CLAUDE_SETTINGS, + launchArgs: "--add-dir ../apps ../lib --verbose --add-dir=/shared", + }, + }); + assert.deepEqual(options.additionalDirectories, [ + "/workspace", + "/attachments", + "../apps", + "../lib", + "/shared", + ]); + assert.isUndefined(options.extraArgs?.["add-dir"]); + }); + it("gives delegated children Claude's subagent prompt cache TTL", () => { const lineage = (relationshipToParent: "fork" | "subagent" | null) => ({ lineage: { @@ -894,8 +931,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { assert.isFunction(canUseTool); const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, Effect.forkScoped, ); const command = { command: "node -e 'console.log(42)'" }; @@ -904,6 +944,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { signal: new AbortController().signal, toolUseID: "tool-bash-accept-edits", requestId: "request-bash-accept-edits", + description: "Check the Node runtime", }), ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( @@ -916,12 +957,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { ); assert.equal(first.type, "request", "the command ran without asking"); if (first.type !== "request") return; - const event = first.event; - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - assert.equal(event.value.runtimeRequest.kind, "command"); + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, + requestId: event!.runtimeRequest.id, decision: "accept", }); assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); @@ -8998,6 +9043,152 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { + case: "an Edit", + toolName: "Edit", + toolInput: { + file_path: "/workspace/src/math.ts", + old_string: " return a - b;", + new_string: " return a + b;", + }, + toolUseResult: { + filePath: "/workspace/src/math.ts", + oldString: " return a - b;", + newString: " return a + b;", + originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [ + " export function add(a: number, b: number) {", + "- return a - b;", + "+ return a + b;", + " }", + ], + }, + ], + userModified: false, + replaceAll: false, + }, + diffStr: + "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", + }, + { + case: "a Write that creates a file", + toolName: "Write", + toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, + toolUseResult: { + type: "create", + filePath: "/workspace/notes.md", + content: "# Notes\n\nShip it.\n", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", + }, + { + case: "a Write that creates an empty file", + toolName: "Write", + toolInput: { file_path: "/workspace/.keep", content: "" }, + toolUseResult: { + type: "create", + filePath: "/workspace/.keep", + content: "", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: undefined, + }, + ])("stores the diff panel patch for $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const toolUseId = `toolu_01${testCase.toolName}Patch`; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), + text: "Change it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${testCase.toolName}_patch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: testCase.toolName, + input: testCase.toolInput, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000793", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000794", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: testCase.toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + ); + yield* harness.awaitTerminal(1); + + const fileChange = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ); + assert.deepEqual( + fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" + ? { diffStr: fileChange.turnItem.diffStr } + : undefined, + { diffStr: testCase.diffStr }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bc8c37bc1ce4..0b3b77e3f476 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -106,6 +106,7 @@ import { resolveIdleCompaction, } from "@t3tools/contracts"; +import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -134,7 +135,10 @@ import { planClaudeSkillDispatch, type ClaudeSkillTarget, } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; -import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; +import { + claudeLaunchArgDirectories, + discoverClaudeSkills, +} from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection, type CompiledClaudeModelSelection, @@ -1472,6 +1476,8 @@ export function makeClaudeQueryOptions(input: { }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { + // claudeLaunchArgDirectories reads every --add-dir into additionalDirectories. + "add-dir": _launchArgAddDir, "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, "append-system-prompt": launchArgAppendSystemPrompt, @@ -1591,6 +1597,7 @@ export function makeClaudeQueryOptions(input: { const additionalDirectories = [ ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), + ...claudeLaunchArgDirectories(input.settings?.launchArgs), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; @@ -2998,6 +3005,44 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +const decodeClaudeFileEditResult = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.optional(Schema.String), + filePath: Schema.String, + content: Schema.optional(Schema.String), + structuredPatch: Schema.mutable( + Schema.Array( + Schema.Struct({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.mutable(Schema.Array(Schema.String)), + }), + ), + ), + }), +); + +function claudeFileChangeText(output: ClaudeNativeToolOutput): string { + const result = + output.type === "structured_tool_use_result" + ? Option.getOrUndefined(decodeClaudeFileEditResult(output.value)) + : undefined; + if (result === undefined) return claudeNativeToolOutputText(output); + const patch = + result.structuredPatch.length === 0 && result.type === "create" && result.content !== undefined + ? structuredPatch("/dev/null", result.filePath, "", result.content) + : { + oldFileName: result.filePath, + newFileName: result.filePath, + oldHeader: undefined, + newHeader: undefined, + hunks: result.structuredPatch, + }; + return patch.hunks.length === 0 ? "" : formatPatch(patch, FILE_HEADERS_ONLY); +} + function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); if (typeof value === "object" && value !== null) { @@ -5540,7 +5585,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const outputText = itemType === "command_execution" ? claudeCommandOutputText(input.output) - : claudeNativeToolOutputText(input.output); + : itemType === "file_change" + ? claudeFileChangeText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { @@ -9624,7 +9671,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const requestKind = providerRequestKindFromClaudeTool(toolName); + // The user approves the command itself; Claude's title and description only summarize it. + const command = inputRecordValue(nativeToolInput, "command"); const prompt = + (typeof command === "string" && command.trim() !== "" ? command : undefined) ?? callbackOptions.title ?? callbackOptions.description ?? callbackOptions.decisionReason ?? diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 69fca8c0825b..c20994423f3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -16,6 +16,7 @@ import { type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type OrchestrationV2RuntimeRequest, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, ProjectId, type ProviderApprovalDecision, @@ -566,6 +567,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -664,7 +666,14 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + ...(input.effort === undefined ? {} : { effort: input.effort }), + collaborationMode: { + mode: "default", + settings: { + model: "gpt-5.4", + ...(input.effort === undefined ? {} : { reasoning_effort: input.effort }), + }, + }, }, }, }, @@ -5129,6 +5138,73 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_DEFERRED_RESULT = { + content: [{ type: "text", text: "An interactive form is being shown to the user." }], + structuredContent: { status: "awaiting_user_submission" }, + }; + const mcpAppDeferredTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: mcpAppTranscript.entries.map((entry) => + entry.type === "emit_inbound" && entry.label === "item/completed/app-tool" + ? { + ...entry, + frame: { + method: "item/completed", + params: { + item: { + ...mcpAppToolItem("inProgress"), + status: "failed", + result: MCP_APP_DEFERRED_RESULT, + }, + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + } + : entry, + ), + }); + + it.effect("captures the app of a tool call that defers to its form", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppDeferredTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "failed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app-deferred"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + assert.equal( + readMcpAppReference(output[MCP_APP_OUTPUT_KEY])?.resourceUri, + MCP_APP_RESOURCE, + ); + assert.deepEqual(output.result, { ...MCP_APP_DEFERRED_RESULT, isError: true }); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; @@ -6211,6 +6287,101 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect.each(["activity", "spawn", "activity then spawn"] as const)( + "creates a child from %s without the parent's effort", + (source) => + Effect.scoped( + Effect.gen(function* () { + const created = yield* Deferred.make(); + const completed = yield* Deferred.make(); + const activity = { + type: "subAgentActivity", + id: "call-codex-resume-spawn", + kind: "started", + agentThreadId: RESUME_CHILD_THREAD, + agentPath: "/root/resume_agent", + }; + const spawn = { + type: "collabAgentToolCall", + id: "call-codex-resume-spawn", + tool: "spawnAgent", + status: "completed", + senderThreadId: RESUME_NATIVE_THREAD, + receiverThreadIds: [RESUME_CHILD_THREAD], + model: "gpt-5.4", + reasoningEffort: "high", + agentsStates: { [RESUME_CHILD_THREAD]: { status: "completed", message: "finished" } }, + }; + const items = + source === "activity" ? [activity] : source === "spawn" ? [spawn] : [activity, spawn]; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: RESUME_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: RESUME_NATIVE_THREAD, + nativeTurnId: RESUME_NATIVE_TURN, + prompt: RESUME_PROMPT, + effort: "low", + }), + ...items.map((item) => ({ + type: "emit_inbound" as const, + frame: { + method: "item/completed", + params: { + item, + threadId: RESUME_NATIVE_THREAD, + turnId: RESUME_NATIVE_TURN, + completedAtMs: 1782622441000, + }, + }, + })), + ], + }), + (event) => + event.type === "app_thread.created" + ? Deferred.succeed(created, event.appThread) + : event.type === "subagent.updated" && event.subagent.status === "completed" + ? Deferred.succeed(completed, event.subagent) + : Effect.void, + ); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-child-effort"), + text: RESUME_PROMPT, + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + const child = yield* Deferred.await(created); + assert.equal( + child.modelSelection.options?.find(({ id }) => id === "reasoningEffort")?.value, + source === "spawn" ? "high" : undefined, + ); + if (source !== "activity") { + const task = yield* Deferred.await(completed); + assert.equal( + task.modelSelection?.options?.find(({ id }) => id === "reasoningEffort")?.value, + "high", + ); + assert.lengthOf( + harness.events.filter((event) => event.type === "app_thread.created"), + 1, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves a subagent result across a trailing empty final and resume", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index e5c3e15a9e68..8f3c1629e816 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2717,9 +2717,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readonly emitInitialPrompt: boolean; }) { const registeredSubagents = yield* Ref.get(subagentThreads); - if (registeredSubagents.has(input.nativeThreadId)) { - return; - } if (input.model && !subagentSelections.has(input.nativeThreadId)) { yield* updateSubagentSelection( input.nativeThreadId, @@ -2727,6 +2724,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( input.reasoningEffort, ); } + if (registeredSubagents.has(input.nativeThreadId)) { + return; + } const now = yield* DateTime.now; const subagentNodeId = idAllocator.derive.nodeFromProviderItem({ @@ -2803,9 +2803,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( parentNodeId: subagentNodeId, activeProviderThreadId: providerThread.id, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: { + // A child's effort can differ from the parent's. Until Codex reports it, show none. + modelSelection: task.modelSelection ?? { ...input.context.input.modelSelection, model: task.model ?? input.context.input.modelSelection.model, + options: (input.context.input.modelSelection.options ?? []).filter( + ({ id }) => id !== "reasoningEffort", + ), }, title: subagentThreadTitle({ parentTitle: input.context.projectionAppThread.title, @@ -3740,7 +3744,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = - item.status === "completed" && item.result != null && item.error == null + item.result != null && item.error == null ? yield* restore( getNativeThreadId(context.providerThread).pipe( Effect.flatMap((threadId) => @@ -3794,7 +3798,13 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? artifacts.turnItem : { ...artifacts.turnItem, - output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result }, + output: { + [MCP_APP_OUTPUT_KEY]: reference, + result: + item.status === "failed" + ? { ...item.result, isError: true } + : item.result, + }, }; yield* emitProviderEvent({ type: "node.updated", diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 1bf294641a01..cf982c9699f7 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -164,6 +164,53 @@ it.each([ ); }); +// regression: #26695 an interrupt-restart steer cancels Copilot's background agents +it("queues a steer for a provider that cannot steer either way", () => { + const projection = dispatchProjection( + capabilities((current) => ({ + ...current, + turns: { + ...current.turns, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + }, + })), + ); + for (const deliveryIntent of ["auto", "steer"] as const) { + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + deliveryIntent, + ), + { type: "queue_after_active" }, + ); + } + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: activeRunId, + }), + { type: "queue_after_active" }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent( + projection, + { type: "start_immediately" }, + "restart", + ), + { type: "restart_active", targetRunId: activeRunId }, + ); + assert.deepEqual( + CommandPolicy.resolveMessageDispatchIntent(projection, { + type: "steer_active", + targetRunId: RunId.make("command-policy-missing-run"), + }), + { type: "steer_active", targetRunId: RunId.make("command-policy-missing-run") }, + ); +}); + it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => { const projection = dispatchProjection(baseCapabilities); const maintenance = { diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 302bb12f797c..869851790177 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -99,11 +99,47 @@ export function resolveMessageDispatchIntent( // Compaction and sign-out cannot accept steers; queue follow-ups. const target = projection.runs.find((run) => run.id === mode.targetRunId); const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId); - return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) + if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { + return { type: "queue_after_active" }; + } + // A provider that can neither steer nor restart its turn takes the message after the turn. + const turns = + target === undefined || !isActiveRun(target) + ? undefined + : providerTurnCapabilities(projection, target); + return turns !== undefined && + !turns.supportsActiveSteering && + !(turns.supportsInterrupt && turns.supportsSteeringByInterruptRestart) && + turns.supportsQueuedMessages ? { type: "queue_after_active" } : mode; } +function isActiveRun(run: OrchestrationV2Run): boolean { + return ( + run.status === "preparing" || + run.status === "starting" || + run.status === "running" || + run.status === "waiting" + ); +} + +function providerTurnCapabilities( + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2Run, +) { + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + const providerSession = + providerThread?.providerSessionId == null + ? undefined + : projection.providerSessions.find( + (candidate) => candidate.id === providerThread.providerSessionId, + ); + return providerSession?.capabilities.turns; +} + function resolveDeliveryIntent( projection: OrchestrationV2ThreadProjection, requestedMode: MessageDispatchMode, @@ -111,13 +147,7 @@ function resolveDeliveryIntent( ): MessageDispatchMode { if (deliveryIntent === undefined) return requestedMode; - const activeRun = projection.runs.findLast( - (run) => - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting", - ); + const activeRun = projection.runs.findLast(isActiveRun); if (activeRun === undefined) return { type: "start_immediately" }; if (deliveryIntent === "steer") { return { type: "steer_active", targetRunId: activeRun.id }; @@ -137,16 +167,7 @@ function resolveDeliveryIntent( return { type: "queue_after_active" }; } - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === activeRun.providerThreadId, - ); - const providerSession = - providerThread?.providerSessionId == null - ? undefined - : projection.providerSessions.find( - (candidate) => candidate.id === providerThread.providerSessionId, - ); - const capabilities = providerSession?.capabilities.turns; + const capabilities = providerTurnCapabilities(projection, activeRun); if (capabilities?.supportsActiveSteering === true) { return { type: "steer_active", targetRunId: activeRun.id }; } diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index c36dfc365af6..61515a053ac3 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -260,6 +260,120 @@ it.effect("marks a never-visited thread's first completion unread", () => }).pipe(Effect.provide(layerTest)), ); +const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* ( + name: string, + snoozeMinutes: number, +) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make(`thread:${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${name}`), + threadId, + projectId: ProjectId.make(`project:${name}`), + title: "Snoozed completion", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch-${name}`), + threadId, + messageId: MessageId.make(`dispatch-${name}`), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make(`interrupt-${name}`), + threadId, + runId: (yield* projections.getThreadProjection(threadId)).runs[0]!.id, + }); + const completedAt = (yield* projections.getThreadProjection(threadId)).runs.at(-1)?.completedAt; + assert.ok(completedAt); + yield* TestClock.adjust("1 second"); + const snoozedUntil = DateTime.add(yield* DateTime.now, { minutes: snoozeMinutes }); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`snooze-${name}`), + threadId, + snoozedUntil: DateTime.formatIso(snoozedUntil), + }); + return { threadId, completedAt, snoozedUntil }; +}); + +it.effect("mark unread retires an expired snooze so a dismissed wake stays dismissed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt, snoozedUntil } = yield* completeAndSnoozeThread( + "mark-unread-expired-snooze", + 1, + ); + yield* TestClock.adjust("2 minutes"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("dismiss-woke"), + threadId, + visitedAt: DateTime.formatIso(snoozedUntil), + }); + const dismissed = yield* projections.getThread(threadId); + assert.deepEqual(dismissed.lastVisitedAt, snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-expired-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...dismissed, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + snoozedUntil: null, + snoozedAt: null, + lastSnoozeWakeAt: snoozedUntil, + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("mark unread leaves a future snooze untouched", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const { threadId, completedAt } = yield* completeAndSnoozeThread( + "mark-unread-future-snooze", + 60, + ); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make("visit-snoozed"), + threadId, + visitedAt: DateTime.formatIso(yield* DateTime.now), + }); + const visited = yield* projections.getThread(threadId); + assert.isNotNull(visited.snoozedUntil); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make("mark-unread-future-snooze"), + threadId, + }); + assert.deepEqual(yield* projections.getThread(threadId), { + ...visited, + lastVisitedAt: DateTime.subtract(completedAt, { milliseconds: 1 }), + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "dispatches metadata, queue resume and request controls without hydrating unrelated history", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8e287f9b5b34..7f396e279912 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -141,6 +141,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -392,6 +393,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -455,6 +461,23 @@ function wakeWorkStartedAt( const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); +/** Unfinished runs (including queued) or open requests block moving a thread to another project. */ +export function threadHasUnfinishedWork(records: { + readonly runs: ReadonlyArray<{ readonly status: string }>; + readonly runtimeRequests: ReadonlyArray<{ readonly status: string }>; +}): boolean { + return ( + records.runs.some( + (run) => + run.status !== "completed" && + run.status !== "failed" && + run.status !== "cancelled" && + run.status !== "interrupted" && + run.status !== "rolled_back", + ) || records.runtimeRequests.some((request) => request.status === "pending") + ); +} + function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { switch (command.type) { case "thread.create": @@ -486,6 +509,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.pull-request-watch.sync": case "thread.pull-request.sync": case "thread.title.regeneration.complete": + case "thread.project.adopt-folder": case "thread.runtime-mode.set": case "thread.interaction-mode.set": case "thread.model-selection.set": @@ -3212,8 +3236,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { - const projection = yield* loadProjectionForCommand(command, ["runs"]); - const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null; + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const latestRunCompletedAt = shell?.latestRunCompletedAt ?? null; if (latestRunCompletedAt === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -3337,8 +3367,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: thread.updatedAt, }; } - case "thread.mark-unread": - return { ...thread, lastVisitedAt: markUnreadVisitedAt }; + case "thread.mark-unread": { + // Retire the wake before rewinding the visit, preserving its settlement timer. + const snoozeExpired = + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) <= DateTime.toEpochMillis(now); + return { + ...thread, + lastVisitedAt: markUnreadVisitedAt, + ...(snoozeExpired + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: thread.snoozedUntil } + : {}), + }; + } case "thread.metadata.update": { const previousRecovery = thread.limitRecovery?.runId === command.limitRecovery?.runId && @@ -3984,6 +4025,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + const dispatchThreadProjectAdoptFolder = Effect.fn( + "orchestrationV2.dispatch.threadProjectAdoptFolder", + )(function* ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.project.adopt-folder" } + >, + events: Ref.Ref>, + ) { + const reject = (cause: string) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} ${cause}`, + }); + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const thread = projection.thread; + if (thread.deletedAt !== null) return yield* reject("is deleted."); + if (threadHasUnfinishedWork(projection)) { + return yield* reject("still has work in progress. Finish or stop it first."); + } + const current = yield* projects.get(thread.projectId).pipe(mapDispatchError(command)); + const target = yield* projects.get(command.projectId).pipe(mapDispatchError(command)); + if (Option.isNone(target) || target.value.deletedAt !== null) { + return yield* reject(`cannot move to missing project ${command.projectId}.`); + } + // The thread always lands in the project root, so the move must not change its cwd. + const cwd = thread.worktreePath ?? Option.getOrUndefined(current)?.workspaceRoot; + if (cwd !== target.value.workspaceRoot) { + return yield* reject(`does not work in ${target.value.workspaceRoot}.`); + } + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { ...thread, projectId: command.projectId, worktreePath: null, updatedAt: now }, + }); + }); + const refuseAboveDispatchModeLimit = Effect.fn("orchestrationV2.dispatch.refuseAboveModeLimit")( function* ( command: OrchestrationV2ServerCommand, @@ -12492,6 +12583,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; + case "thread.project.adopt-folder": + yield* dispatchThreadProjectAdoptFolder(command, events); + break; case "message.dispatch": { // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. const thread = yield* projectionStore @@ -13383,6 +13477,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .pipe(Effect.mapError(shellProjectionError)); }, ), + countThreads: Effect.fn("OrchestratorV2.countThreads")(function () { + return projectionStore.countThreads().pipe(Effect.mapError(shellProjectionError)); + }), + listActiveShellIndex: Effect.fn("OrchestratorV2.listActiveShellIndex")(function () { + return projectionStore.listActiveShellIndex().pipe(Effect.mapError(shellProjectionError)); + }), listOlderSettledThreads: Effect.fn("OrchestratorV2.listOlderSettledThreads")(function (query) { return projectionStore .listOlderSettledThreads(query) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 40b4965143ce..5278754ffe24 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -26,6 +26,7 @@ import { ThreadId, TurnItemId, type ThreadPullRequestLink, + isLiveSubagentShell, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -76,6 +77,33 @@ const traceSqlStatements = () => { return { statements, tracer }; }; +/** The shell index flags every live shell and matches its shells on the fields subscriber trims read. */ +const assertShellIndexCoversShells = Effect.fnUntraced(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const shells = (yield* store.getShellSnapshot({ location: "active" })).threads; + const index = yield* store.listActiveShellIndex(); + const iso = (value: DateTime.Utc | null | undefined) => + value == null ? null : DateTime.formatIso(value); + const trimmed = ( + row: Pick< + ProjectionStore.ActiveShellIndexEntry, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" + >, + ) => ({ + id: row.id, + projectId: row.projectId, + lineage: row.lineage, + settledOverride: row.settledOverride, + settledAt: iso(row.settledAt), + pinnedAt: iso(row.pinnedAt), + snoozedUntil: iso(row.snoozedUntil), + }); + assert.deepEqual(index.map(trimmed), shells.map(trimmed)); + shells.forEach((shell, position) => { + if (isLiveSubagentShell(shell)) assert.isTrue(index[position]?.mayBeLive, shell.id); + }); +}); + const addRolledBackRecoveryCandidate = Effect.fn("addRolledBackRecoveryCandidate")(function* ( suffix: string, ) { @@ -272,6 +300,7 @@ it.effect("SQL shells flag only an active turn blocked solely on T3 thread waits assert.equal(shell?.status, "running"); assert.equal(shell?.blockedOnThreadWait ?? false, expected); } + yield* assertShellIndexCoversShells(); }); const wait = { ...itemBase, @@ -531,6 +560,7 @@ it.effect("SQL shells use the latest subagent owner for duplicate provider-threa const listed = (yield* store.getShellSnapshot()).threads.find( (thread) => thread.id === targetThreadId, ); + yield* assertShellIndexCoversShells(); for (const shell of [yield* store.getThreadShell(targetThreadId), listed]) { assert.equal(shell?.providerInstanceId, otherInstanceId); assert.equal(shell?.activeRunId, null); @@ -1336,6 +1366,7 @@ it.effect("SQL list older settled threads below a cursor", () => const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + yield* assertShellIndexCoversShells(); const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); assert.deepEqual(scoped, { threadIds: [], @@ -1374,6 +1405,70 @@ it.effect("lists only active subagent IDs without loading shell data", () => }).pipe(Effect.provide(layerTest)), ); +// regression: #25621 SQLite rejects statements binding more than 32,766 variables +it.effect("lists every thread in the shell snapshot past SQLite's bound variable limit", () => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const templateId = ThreadId.make("thread:many:00000"); + yield* store.apply({ + id: EventId.make(`event:${templateId}:created`), + type: "thread.created", + threadId: templateId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: templateId, + projectId: ProjectId.make("project:many"), + title: "Many", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: templateId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + const threadCount = 32_767; + yield* sql` + CREATE TEMP TABLE many_threads AS + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${threadCount - 1}) + SELECT t.*, n.i AS clone_index FROM orchestration_v2_projection_threads t, n + WHERE t.thread_id = ${templateId} + `; + yield* sql` + UPDATE many_threads SET + thread_id = printf('thread:many:%05d', clone_index), + payload_json = json_set( + payload_json, + '$.id', printf('thread:many:%05d', clone_index), + '$.lineage.rootThreadId', printf('thread:many:%05d', clone_index) + ) + `; + yield* sql`ALTER TABLE many_threads DROP COLUMN clone_index`; + yield* sql`INSERT INTO orchestration_v2_projection_threads SELECT * FROM many_threads`; + + const [unscoped, active] = yield* Effect.all([ + store.getShellSnapshot(), + store.getShellSnapshot({ location: "active" }), + ]); + assert.strictEqual(unscoped.threads.length, threadCount); + assert.strictEqual(active.threads.length, threadCount); + }).pipe(Effect.provide(layerTest)), +); + it.layer(layerTest)("ProjectionStoreV2", (it) => { // regression: #21969 keep corrupt plan JSON in the typed read error it.effect("returns a typed read error for malformed stored plan JSON", () => @@ -3734,6 +3829,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( (row) => row.id === threadId, )!; + yield* assertShellIndexCoversShells(); for (const questionShell of [ sqlShell, ProjectionStore.threadShellFromProjection(projection), @@ -4798,6 +4894,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET status = 'running', payload_json = json_set(payload_json, '$.status', 'running') WHERE run_id = ${runId} `; + yield* assertShellIndexCoversShells(); const secretOnlyShell = (yield* projectionStore.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, ); @@ -6404,6 +6501,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { (candidate) => candidate.id === threadId, ); assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); + yield* assertShellIndexCoversShells(); return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; }); @@ -6578,10 +6676,23 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { completedAt: releasedAt, }, }); + const { statements, tracer } = traceSqlStatements(); assert.deepEqual( - (yield* store.getRuntimeRecoveryProjection(threadId)).runs.map((run) => run.id), + (yield* store + .getRuntimeRecoveryProjection(threadId) + .pipe(Effect.withTracer(tracer))).runs.map((run) => run.id), [runId, newerRunId], ); + // Live-item filters must hit the active partial indexes, not walk the thread's history. + const sql = yield* SqlClient.SqlClient; + for (const statement of statements.filter((query) => query.includes("turn_items"))) { + const plan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${statement}`, + )).map((row) => row.detail); + for (const detail of plan) { + assert.notMatch(detail, /turn_items_(thread_ordinal|thread_run)_idx \(thread_id=\?\)$/); + } + } }), ); }); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 900fa448c40e..f215eb821270 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -395,6 +395,15 @@ export interface ShellSnapshotOptions { readonly threadIds?: ReadonlyArray; } +/** What a shell subscriber's trims read from an active thread, without building its shell. */ +export interface ActiveShellIndexEntry extends Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pinnedAt" | "snoozedUntil" +> { + /** False only when no run, request, work item, or watch can make the shell live. */ + readonly mayBeLive: boolean; +} + export interface OlderSettledThreadsQuery { readonly before: OrchestrationV2OlderSettledCursor; readonly projectIds?: ReadonlyArray | undefined; @@ -452,6 +461,13 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Threads that are not deleted, archived ones included. */ + readonly countThreads: () => Effect.Effect; + /** Every active thread, in shell order. */ + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + ProjectionStoreV2Error + >; /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, @@ -5980,7 +5996,7 @@ export const layer: Layer.Layer = worktreePath === undefined ? sql`` : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` - }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -5993,14 +6009,14 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, run_id, ordinal FROM orchestration_v2_projection_runs - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellRunItemCounts = (threadIds: ReadonlyArray) => sql` SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE run_id IS NOT NULL AND thread_id IN ${sql.in(threadIds)} + WHERE run_id IS NOT NULL AND thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id `; @@ -6015,7 +6031,7 @@ export const layer: Layer.Layer = FROM ( SELECT thread_id, run_id, COUNT(*) AS item_count FROM orchestration_v2_projection_turn_items - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY thread_id, run_id ) counts LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_id @@ -6032,7 +6048,7 @@ export const layer: Layer.Layer = : sql` SELECT thread_id, payload_json FROM orchestration_v2_projection_provider_threads - WHERE thread_id IN ${sql.in(threadIds)} + WHERE thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) `; const selectShellPendingTurnItemRows = ( @@ -6048,7 +6064,7 @@ export const layer: Layer.Layer = WHERE i.type IN ('command_execution', 'dynamic_tool', 'subagent') AND i.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') AND (i.run_id IS NULL OR r.status <> 'rolled_back') - ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN ${sql.in(threadIds)}`} + ${threadIds === undefined ? sql`` : sql`AND i.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ${ activeRunIds.length === 0 ? sql`` @@ -6057,7 +6073,7 @@ export const layer: Layer.Layer = UNION ALL SELECT i.thread_id, i.payload_json, NULL AS owning_provider_instance_id FROM orchestration_v2_projection_turn_items i - WHERE i.run_id IN ${sql.in(activeRunIds)} + WHERE i.run_id IN (SELECT value FROM json_each(${encodeIdList(activeRunIds)})) AND i.type IN ('file_change', 'file_search', 'web_search', 'compaction') AND i.status IN ('running', 'waiting') ` @@ -6654,6 +6670,98 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const countThreads = Effect.fn("ProjectionStore.countThreads")(function* () { + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_threads WHERE deleted_at IS NULL + `; + return rows[0]?.count ?? 0; + }, Effect.mapError(shellSnapshotReadError)); + + const listActiveShellIndex = Effect.fn("ProjectionStore.listActiveShellIndex")(function* () { + const rows = yield* sql<{ + readonly thread_id: string; + readonly project_id: string; + readonly relationship_to_parent: string | null; + readonly parent_thread_id: string | null; + readonly root_thread_id: string; + readonly settled_override: string | null; + readonly settled_at: string | null; + readonly pinned_at: string | null; + readonly snoozed_until: string | null; + readonly may_be_live: number; + }>` + SELECT + t.thread_id, + t.project_id, + json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, + json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, + json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.settledOverride') AS settled_override, + json_extract(t.payload_json, '$.settledAt') AS settled_at, + json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, + json_extract(t.payload_json, '$.snoozedUntil') AS snoozed_until, + -- A superset of isLiveSubagentShell: the sources of its run status, pending request, + -- and pending background work. + ( + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs r + WHERE r.thread_id = t.thread_id + AND r.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runtime_requests request + WHERE request.thread_id = t.thread_id AND request.status = 'pending' + ) + -- Same predicates as the shell's pending item and link reads, for their partial indexes. + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items item + WHERE item.thread_id = t.thread_id + AND item.type IN ('command_execution', 'dynamic_tool', 'subagent') + AND item.status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items link_request + WHERE link_request.thread_id = t.thread_id + AND link_request.type = 'link_request' AND link_request.status = 'waiting' + ) + OR EXISTS ( + SELECT 1 FROM orchestration_v2_projection_provider_threads provider_thread + WHERE provider_thread.thread_id = t.thread_id + AND json_array_length(provider_thread.payload_json, '$.pendingBackgroundTasks') > 0 + ) + OR EXISTS ( + SELECT 1 FROM json_each(t.payload_json, '$.pullRequests') link + WHERE json_type(link.value, '$.watch') IS NOT NULL + ) + ) AS may_be_live + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + ORDER BY t.updated_at ASC, t.thread_id ASC + `; + return rows.map((row): ActiveShellIndexEntry => ({ + id: ThreadId.make(row.thread_id), + projectId: ProjectId.make(row.project_id), + lineage: { + parentThreadId: + row.parent_thread_id === null ? null : ThreadId.make(row.parent_thread_id), + relationshipToParent: + row.relationship_to_parent === "fork" || row.relationship_to_parent === "subagent" + ? row.relationship_to_parent + : null, + rootThreadId: ThreadId.make(row.root_thread_id), + }, + settledOverride: + row.settled_override === "settled" || row.settled_override === "active" + ? row.settled_override + : null, + settledAt: row.settled_at === null ? null : DateTime.makeUnsafe(row.settled_at), + pinnedAt: row.pinned_at === null ? null : DateTime.makeUnsafe(row.pinned_at), + snoozedUntil: row.snoozed_until === null ? null : DateTime.makeUnsafe(row.snoozed_until), + mayBeLive: row.may_be_live === 1, + })); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -6808,6 +6916,8 @@ export const layer: Layer.Layer = apply, getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), + countThreads: readOnReader(countThreads), + listActiveShellIndex: readOnReader(listActiveShellIndex), readShellSnapshot: readOnReader(readShellSnapshot), listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 3fecc1c7d2d6..7f1006f1c5ec 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -46,6 +46,9 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import { makeProviderEventRoutingState, @@ -68,8 +71,16 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const makeLayerTest = (ingestorEventSink: Layer.Layer) => +const layerEmptyInstances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), +}); +const makeLayerTest = ( + ingestorEventSink: Layer.Layer, + instances = layerEmptyInstances, +) => Layer.mergeAll( + instances, layerTestDatabase, layerTestStores, layerTestEventSink, @@ -79,6 +90,7 @@ const makeLayerTest = (ingestorEventSink: Layer.Layer + Effect.scoped( + Effect.gen(function* () { + const refreshed = yield* Deferred.make(); + const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe( + Effect.provide( + Layer.mock(ProviderAdapter.ProviderAdapterV2)({ + instanceId: ProviderInstanceId.make("cursor-work"), + driver: ProviderDriverKind.make("cursor"), + }), + ), + ); + const instance: ProviderInstance = { + instanceId: ProviderInstanceId.make("cursor-work"), + driverKind: ProviderDriverKind.make("cursor"), + continuationIdentity: { + driverKind: ProviderDriverKind.make("cursor"), + continuationKey: "work", + }, + enabled: true, + displayName: undefined, + orchestrationAdapter: adapter, + textGeneration: { + generateBranchName: () => Effect.die("unused"), + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateThreadTitle: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + }, + snapshot: { + refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid), + getSnapshot: Effect.die("unused"), + refresh: Effect.die("unused"), + resolveMaintenance: () => Effect.die("unused"), + applyUsageLimits: () => Effect.die("unused"), + streamChanges: Stream.empty, + }, + }; + const lookups: ProviderInstanceId[] = []; + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.sync(() => { + lookups.push(id); + return id === instance.instanceId ? instance : undefined; + }), + }); + yield* Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const input = { + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: instance.instanceId, + threadId: threadEvent.threadId, + }; + for (const failureClass of ["validation_error", "usage_limit"] as const) { + const stored = yield* ingestor.ingestNormalized({ + ...input, + event: { + type: "turn.terminal", + driver: instance.driverKind, + providerThreadId: idAllocator.derive.providerThread({ + driver: instance.driverKind, + nativeThreadId: "limited", + }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: instance.driverKind, + nativeTurnId: failureClass, + }), + runOrdinal: 1, + failureItemOrdinal: 1, + status: "failed", + failure: makeProviderFailure({ + class: failureClass, + message: "Provider rejected turn.", + }), + threadDisposition: "reusable", + }, + }); + assert.equal(stored.length, 1); + if (failureClass === "validation_error") assert.deepEqual(lookups, []); + } + assert.deepEqual(lookups, [instance.instanceId]); + yield* Deferred.await(refreshed); + }).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances))); + }), + ), +); + it.effect("records accepted billed turn usage once without billing the context window", () => { const recorded: Array>> = []; const layerAnalytics = Layer.succeed(ProviderEventIngestor.ProviderTurnAnalytics, { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 6beb4d0e5547..06f444776cb3 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -34,6 +34,7 @@ import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -326,6 +327,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -336,6 +338,8 @@ export const layer: Layer.Layer< const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const analytics = yield* ProviderTurnAnalytics; const agentScope = yield* AgentScope; + const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const scope = yield* Effect.scope; const completedTurnAnalytics = new Set(); const makeDomainEvent = @@ -794,6 +798,23 @@ export const layer: Layer.Layer< }, (effect, input) => effect.pipe( + Effect.tap((storedEvents) => + Effect.gen(function* () { + if ( + storedEvents.length === 0 || + input.event.type !== "turn.terminal" || + input.event.status !== "failed" || + input.event.failure.class !== "usage_limit" + ) + return; + const instance = yield* instances.getInstance(input.providerInstanceId); + if (!instance?.snapshot.refreshUsage) return; + yield* instance.snapshot.refreshUsage.pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(scope, { startImmediately: true }), + ); + }), + ), Effect.flatMap((storedEvents) => Effect.gen(function* () { if (input.event.type !== "app_thread.created") return storedEvents; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index c08f8fb2a4aa..97f237a9df05 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -47,6 +47,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -559,6 +560,10 @@ function layerTest(input: { const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerConfiguredEventSink, IdAllocator.layer, layerTestStores, diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index a39f6b7f036a..1d79e87eb9b9 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -213,6 +213,8 @@ it.effect( getOpenRemoteDelegatedTasks: Effect.die("unused getOpenRemoteDelegatedTasks"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + countThreads: () => Effect.die("unused countThreads"), + listActiveShellIndex: () => Effect.die("unused listActiveShellIndex"), listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getThreadShell: () => Effect.die("unused getThreadShell"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 92b0271e8a84..724456193c22 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1464,6 +1464,34 @@ effectIt.effect("leaves the run starting when a session-open failure will be ret }), ); +effectIt.effect("fails the run without a retry when the provider needs sign-in", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + openFailure: new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ProviderDriverKind.make("antigravity"), + providerSessionId: ProviderSessionId.make("new-provider-session"), + requiresSignIn: true, + }), + }); + + yield* harness.startWithRetry; + + expect(harness.open).toHaveBeenCalledOnce(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + status: "failed", + failure: { + class: "provider_error", + message: "Failed to open antigravity provider session new-provider-session.", + }, + }, + ]); + }), +); + effectIt.effect("does not terminalize a provider-session open interruption", () => Effect.gen(function* () { const harness = makeLocalCommandHarness({ text: "Continue", interruptOpen: true }); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index b55650691980..2f25f5823e42 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -93,6 +93,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError; @@ -56,7 +57,7 @@ export const bufferShellLiveStream = ( limits, ); -export function buildActiveShellSnapshot(input: { +function buildActiveShellSnapshot(input: { readonly projects: ReadonlyArray; readonly threads: OrchestrationV2ThreadShellSnapshot; readonly snapshotSequence: number; @@ -70,26 +71,119 @@ export function buildActiveShellSnapshot(input: { }; } +type ShellTrimRow = Pick< + OrchestrationV2ThreadShell, + "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" +>; + /** - * Idle subagent children have no sidebar row, so clients that opt in read them from their thread - * family instead. A child stays while it is live or the parent of another child, so the request - * and working walks over shell rows never meet a gap in a nested chain. + * What a subscriber's trims read across every active row. Computed from full shells or from the + * store's index, so a snapshot read only for the rows it sends trims the same way. */ -export function omitIdleSubagentShells( - threads: ReadonlyArray, -): ReadonlyArray { - const parentIds = new Set(); - for (const thread of threads) { - if (thread.lineage.relationshipToParent === "subagent" && thread.lineage.parentThreadId) { - parentIds.add(thread.lineage.parentThreadId); +interface ShellTrimFacts { + readonly subagentParentIds: ReadonlySet; + readonly subagentThreadIds: Set; + /** Undefined when no thread is settled, so none is older. */ + readonly olderSettledBeforeMs: number | undefined; + readonly olderSettledCounts: ReadonlyArray; +} + +const NEWEST_SETTLED_KEPT = 25; + +function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { + const subagentParentIds = new Set(); + const subagentThreadIds = new Set(); + const settledTimes: Array = []; + for (const row of rows) { + if (row.lineage.relationshipToParent === "subagent") { + subagentThreadIds.add(row.id); + if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); + } else if (row.settledOverride === "settled" && row.settledAt !== null) { + settledTimes.push(DateTime.toEpochMillis(row.settledAt)); + } + } + settledTimes.sort((left, right) => right - left); + // With fewer than 25, the oldest one is the cut, so nothing is older. + const olderSettledBeforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1); + const counts = new Map(); + if (olderSettledBeforeMs !== undefined) { + for (const row of rows) { + if (isOlderSettledThreadShell(row, olderSettledBeforeMs)) { + counts.set(row.projectId, (counts.get(row.projectId) ?? 0) + 1); + } } } + return { + subagentParentIds, + subagentThreadIds, + olderSettledBeforeMs, + olderSettledCounts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; +} + +function subagentAncestorIds(threads: ReadonlyArray>) { + const ancestorIds = new Set(); + for (const thread of threads) { + if (thread.lineage.relationshipToParent !== "subagent") continue; + if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); + ancestorIds.add(thread.lineage.rootThreadId); + } + return ancestorIds; +} + +function omitIdleSubagentShellsWith( + threads: ReadonlyArray, + facts: Pick, +): ReadonlyArray { return threads.filter( (thread) => thread.lineage.relationshipToParent !== "subagent" || isLiveSubagentShell(thread) || - parentIds.has(thread.id), + facts.subagentParentIds.has(thread.id), + ); +} + +function omitOlderSettledShellsWith( + input: { + readonly threads: ReadonlyArray; + readonly snapshotSequence: number; + readonly now: DateTime.Utc; + }, + facts: Pick, +): { + readonly threads: ReadonlyArray; + readonly olderSettled: OrchestrationV2OlderSettledThreads; +} { + const nowMs = DateTime.toEpochMillis(input.now); + const beforeMs = facts.olderSettledBeforeMs ?? nowMs; + const ancestorIds = subagentAncestorIds(input.threads); + const threads = input.threads.filter( + (thread) => + !isOlderSettledThreadShell(thread, beforeMs) || + thread.pinnedAt != null || + (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || + isLiveSubagentShell(thread) || + ancestorIds.has(thread.id), ); + return { + threads, + olderSettled: { + before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), + asOfSequence: input.snapshotSequence, + counts: facts.olderSettledCounts, + }, + }; +} + +/** + * Idle subagent children have no sidebar row, so clients that opt in read them from their thread + * family instead. A child stays while it is live or the parent of another child, so the request + * and working walks over shell rows never meet a gap in a nested chain. + */ +export function omitIdleSubagentShells( + threads: ReadonlyArray, +): ReadonlyArray { + return omitIdleSubagentShellsWith(threads, shellTrimFacts(threads)); } /** @@ -103,18 +197,13 @@ export function activeShellRowsForSubscriber( readonly threads: ReadonlyArray; readonly subagentThreadIds: Set; } { + const facts = shellTrimFacts(threads); return { - threads: omitIdleSubagents ? omitIdleSubagentShells(threads) : threads, - subagentThreadIds: new Set( - threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ), + threads: omitIdleSubagents ? omitIdleSubagentShellsWith(threads, facts) : threads, + subagentThreadIds: facts.subagentThreadIds, }; } -const NEWEST_SETTLED_KEPT = 25; - /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while @@ -128,77 +217,107 @@ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; readonly olderSettled: OrchestrationV2OlderSettledThreads; } { - const nowMs = DateTime.toEpochMillis(input.now); - const settledTimes = input.threads - .flatMap((thread) => - thread.lineage.relationshipToParent !== "subagent" && - thread.settledOverride === "settled" && - thread.settledAt !== null - ? [DateTime.toEpochMillis(thread.settledAt)] + return omitOlderSettledShellsWith(input, shellTrimFacts(input.threads)); +} + +interface ShellSubscriberOptions { + readonly omitIdleSubagents: boolean; + readonly omitOlderSettled: boolean; +} + +function trimShellSnapshot( + base: OrchestrationV2ShellSnapshot, + facts: ShellTrimFacts, + options: ShellSubscriberOptions & { readonly now: DateTime.Utc }, +): OrchestrationV2ShellSnapshot { + const threads = options.omitIdleSubagents + ? omitIdleSubagentShellsWith(base.threads, facts) + : base.threads; + if (!options.omitOlderSettled) return { ...base, threads }; + const older = omitOlderSettledShellsWith( + { threads, snapshotSequence: base.snapshotSequence, now: options.now }, + facts, + ); + return { ...base, threads: older.threads, olderSettled: older.olderSettled }; +} + +/** + * The threads whose shells a trimmed snapshot can contain: a superset of what the trims keep, so + * trimming the shells read for these ids gives the snapshot trimming every shell would. + */ +function shellSubscriberThreadIds( + index: ReadonlyArray, + options: ShellSubscriberOptions, +): ReadonlyArray { + const facts = shellTrimFacts(index); + const subagents = index.filter( + (entry) => + entry.lineage.relationshipToParent === "subagent" && + (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), + ); + const ancestorIds = subagentAncestorIds(subagents); + const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); + const beforeMs = facts.olderSettledBeforeMs; + return index.flatMap((entry) => + entry.lineage.relationshipToParent === "subagent" + ? keptSubagentIds.has(entry.id) + ? [entry.id] + : [] + : !options.omitOlderSettled || + beforeMs === undefined || + !isOlderSettledThreadShell(entry, beforeMs) || + entry.pinnedAt != null || + entry.snoozedUntil != null || + entry.mayBeLive || + ancestorIds.has(entry.id) + ? [entry.id] : [], - ) - .sort((left, right) => right - left); - // With fewer than 25, the oldest one is the cut, so nothing is older. - const beforeMs = settledTimes[NEWEST_SETTLED_KEPT - 1] ?? settledTimes.at(-1) ?? nowMs; - const ancestorIds = new Set(); - for (const thread of input.threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; - if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); - ancestorIds.add(thread.lineage.rootThreadId); - } - const counts = new Map(); - const threads = input.threads.filter((thread) => { - if (!isOlderSettledThreadShell(thread, beforeMs)) return true; - counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); - return ( - thread.pinnedAt != null || - (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) || - isLiveSubagentShell(thread) || - ancestorIds.has(thread.id) - ); - }); - return { - threads, - olderSettled: { - before: DateTime.formatIso(DateTime.makeUnsafe(beforeMs)), - asOfSequence: input.snapshotSequence, - counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), - }, - }; + ); } /** - * The snapshot a shell subscriber gets for its opt-ins, and the subagent ids whose transcript - * events its stream drops (from every row, so an omitted child that wakes keeps that filter). + * A subscriber's active shell snapshot, read like `loadShellSnapshotParts`. With an opt-in, the + * store's index decides which shells to read, so threads the trims drop are never built. */ -export function shellSnapshotForSubscriber( - base: OrchestrationV2ShellSnapshot, - options: { - readonly omitIdleSubagents: boolean; - readonly omitOlderSettled: boolean; - readonly now: DateTime.Utc; - }, -): { - readonly snapshot: OrchestrationV2ShellSnapshot; - readonly subagentThreadIds: Set; -} { - const rows = activeShellRowsForSubscriber(base.threads, options.omitIdleSubagents); - if (!options.omitOlderSettled) { +export const loadSubscriberShellSnapshot = Effect.fn("ShellStream.loadSubscriberShellSnapshot")( + function* (input: { + readonly sql: SqlClient.SqlClient; + readonly listShellIndex: Effect.Effect, E1>; + readonly readThreads: ( + threadIds: ReadonlyArray | undefined, + ) => Effect.Effect, E2>; + readonly listProjects: Effect.Effect, E4>; + readonly latestSequence: Effect.Effect; + readonly options: ShellSubscriberOptions; + }) { + const trims = input.options.omitIdleSubagents || input.options.omitOlderSettled; + const read = yield* input.sql + .withTransaction( + Effect.gen(function* () { + const index = trims ? yield* input.listShellIndex : null; + return { + index, + decodeThreads: yield* input.readThreads( + index === null ? undefined : shellSubscriberThreadIds(index, input.options), + ), + projects: yield* input.listProjects, + snapshotSequence: yield* input.latestSequence, + }; + }), + ) + .pipe(NodeSqliteClient.readOnly); + const base = buildActiveShellSnapshot({ + projects: read.projects, + threads: yield* read.decodeThreads, + snapshotSequence: read.snapshotSequence, + }); + const facts = shellTrimFacts(read.index ?? base.threads); return { - snapshot: { ...base, threads: rows.threads }, - subagentThreadIds: rows.subagentThreadIds, + snapshot: trimShellSnapshot(base, facts, { ...input.options, now: yield* DateTime.now }), + subagentThreadIds: facts.subagentThreadIds, }; - } - const older = omitOlderSettledShells({ - threads: rows.threads, - snapshotSequence: base.snapshotSequence, - now: options.now, - }); - return { - snapshot: { ...base, threads: older.threads, olderSettled: older.olderSettled }, - subagentThreadIds: rows.subagentThreadIds, - }; -} + }, +); /** * One transaction covers the reads so they agree; the threads are decoded after it commits, diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index d8ac4769e140..1015e44694c8 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -447,16 +447,18 @@ const composerSelection = { const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ( name: string, canRestart = false, + canSteer = true, ) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; const steered: string[] = []; + const interrupted: ProviderTurnId[] = []; const capabilities = { ...CodexProviderCapabilitiesV2, turns: { ...CodexProviderCapabilitiesV2.turns, - supportsActiveSteering: true, + supportsActiveSteering: canSteer, supportsSteeringByInterruptRestart: canRestart, }, }; @@ -533,7 +535,10 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* Effect.sync(() => { steered.push(turn.message.text); }), - interruptTurn: () => Effect.void, + interruptTurn: ({ providerTurnId }) => + Effect.sync(() => { + interrupted.push(providerTurnId); + }), respondToRuntimeRequest: () => Effect.void, readThreadSnapshot: () => Effect.die("unused"), rollbackThread: () => Effect.die("unused"), @@ -588,7 +593,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* yield* Fiber.join(running); return threadId; }); - return { cwd, events, started, steered, layer, startFirstTurn }; + return { cwd, events, started, steered, interrupted, layer, startFirstTurn }; }); it.effect("sends a selected held message once and preserves the remaining queue order", () => @@ -938,6 +943,82 @@ it.effect("starts a steer that missed the turn on the saved next-turn selection" ), ); +// regression: #26695 cancelling a Copilot prompt to steer it kills its background agents +it.effect("queues a steer until the turn ends when the provider cannot steer it", () => + Effect.scoped( + Effect.gen(function* () { + const { events, started, steered, interrupted, layer, startFirstTurn } = + yield* nextTurnSelectionHarness("steering-unsupported", false, false); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const first = started[0]!; + const messageId = MessageId.make("message:steer"); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("steer"), + threadId, + messageId, + text: "follow up", + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + deliveryIntent: "steer", + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + + const held = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, []); + assert.deepEqual(interrupted, []); + assert.equal(started.length, 1); + assert.equal(held.runs.find((run) => run.id === first.runId)?.status, "running"); + const queued = held.runs.find((run) => run.userMessageId === messageId); + assert.equal(queued?.status, "queued"); + + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.id === first.runId && + event.payload.status === "waiting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + const turn = held.providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: first.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* orchestrator.resumeQueuedRuns; + yield* worker.drain(); + + assert.deepEqual(interrupted, []); + assert.equal(started.length, 2); + assert.equal(started[1]?.runId, queued?.id); + assert.equal(started[1]?.message.text, "follow up"); + }).pipe(Effect.provide(layer)); + }), + ), +); + // Codex flushes held drafts after the final answer; reserve their pre-steer position before turn end. it.effect("keeps a held commentary draft before the steer when turn end flushes it", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index d3506649f6a6..95abcbaf5c1a 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { makeSubagentChildThread, @@ -26,7 +27,9 @@ import { endRunlessRootTurns, } from "@t3tools/provider-core/server/subagentProjection"; -import { emptyProjection } from "./ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -117,8 +120,62 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it.effect.each([ + ["sql", ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))], +] as const)( + "%s: a pinned parent's subagent child is born unpinned and settles once idle", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const parentThread = { ...makeParentThread(), pinnedAt: parentCreatedAt, pinOrderKey: "a0" }; + const childThread = makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: NodeId.make("node:subagent-parent"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.equal(parentThread.pinnedAt, parentCreatedAt); + assert.equal(parentThread.pinOrderKey, "a0"); + assert.isNull(childThread.pinnedAt); + assert.isNull(childThread.pinOrderKey); + + for (const thread of [parentThread, childThread]) { + yield* store.apply({ + id: EventId.make(`event:${thread.id}`), + type: "thread.created", + threadId: thread.id, + occurredAt: thread.createdAt, + payload: thread, + }); + } + const savedParent = yield* store.getThread(parentThreadId); + assert.deepEqual(savedParent.pinnedAt, parentCreatedAt); + assert.equal(savedParent.pinOrderKey, "a0"); + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((candidate) => candidate.id), + [childThreadId], + ); + const settledAt = resolveAutoSettlementAt({ + thread: candidates[0]!, + pullRequest: null, + nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })), + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }); + assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); + }).pipe(Effect.provide(testLayer)), +); + function taskFixture() { - const projection = emptyProjection({ + const projection = ProjectionStore.emptyProjection({ type: "thread.created", id: EventId.make("task-fixture"), threadId: parentThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index b484c30363ee..c8b99569517a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1501,6 +1501,9 @@ it.effect("replays a server-allocated launch", () => assert.equal(first.threadId, retry.threadId); assert.isFalse(first.resumed); assert.isTrue(retry.resumed); + // Clients drop their pending copy once the thread reaches the message's commit. + assert.isNumber(first.sequence); + assert.equal(retry.sequence, first.sequence); assert.equal(harness.runSetup.mock.calls.length, 1); assert.equal(retry.projection.messages.length, 1); assert.equal(retry.projection.runs.length, 1); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 7731094795c9..cbde670b8ccb 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -124,6 +124,7 @@ export interface ThreadLaunchResult { readonly projection: OrchestrationV2ThreadProjection; /** The launch's last write (the initial message, else the thread claim) was accepted before this call. */ readonly resumed: boolean; + readonly sequence?: number; } export class ThreadLaunchError extends Schema.TaggedError()( @@ -515,12 +516,9 @@ const make = Effect.gen(function* () { // Update parents first so children inherit names; reread each pass for newly updated descendants. const visited = new Set([threadId]); while (true) { - const snapshot = yield* threads.getShellSnapshot(); + const snapshot = yield* threads.getShellSnapshot({ worktreePath: worktreeCwd }); const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => - !visited.has(thread.id) && - thread.worktreePath === worktreeCwd && - thread.branch === oldBranch, + (thread) => !visited.has(thread.id) && thread.branch === oldBranch, ); if (sharing.length === 0) break; for (const thread of sharing) { @@ -950,6 +948,7 @@ const make = Effect.gen(function* () { return yield* mapError(input, "resolve-project", threadId)("Project identity changed."); } let runId: RunId | null = null; + let messageSequence: number | undefined; let messageWasAlreadyAccepted = false; if (input.initialMessage !== undefined) { const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); @@ -996,6 +995,7 @@ const make = Effect.gen(function* () { ); // A concurrent retry can read no receipt and still replay the dispatch. messageWasAlreadyAccepted ||= dispatched.replayed === true; + messageSequence = dispatched.sequence; const runCreated = dispatched.storedEvents.find( (stored) => stored.event.type === "run.created", ); @@ -1062,6 +1062,7 @@ const make = Effect.gen(function* () { input.initialMessage === undefined ? Option.isSome(launchReceipt) || claimed.replayed === true : messageWasAlreadyAccepted, + ...(messageSequence === undefined ? {} : { sequence: messageSequence }), }; }); }, @@ -1071,12 +1072,14 @@ const make = Effect.gen(function* () { "ThreadLaunchService.prepareRun", )(function* (input) { const scheduled = yield* Effect.gen(function* () { - const projection = yield* threads.getThreadProjection(input.threadId); - const run = projection.runs.find((candidate) => candidate.id === input.runId); + const { thread, runs } = yield* threads.getThreadRecords(input.threadId, ["runs"], { + runIds: [input.runId], + }); + const run = runs.find((candidate) => candidate.id === input.runId); const workspacePreparation = run?.workspacePreparation; if (run?.status !== "preparing" || workspacePreparation === undefined) return; if (!(yield* reservePreparation(input.commandId))) return; - yield* scheduleRunPreparation(input, projection, run, workspacePreparation).pipe( + yield* scheduleRunPreparation(input, thread, run, workspacePreparation).pipe( Effect.onError(() => releasePreparation(input.commandId)), ); }).pipe(Effect.exit); @@ -1098,30 +1101,33 @@ const make = Effect.gen(function* () { return dispatched; }); - const scheduleRunPreparation = ( + const scheduleRunPreparation = Effect.fn("ThreadLaunchService.scheduleRunPreparation")(function* ( input: ThreadLaunchPreparationInput, - projection: OrchestrationV2ThreadProjection, + thread: OrchestrationV2ThreadProjection["thread"], run: OrchestrationV2ThreadProjection["runs"][number], workspacePreparation: ThreadLaunchWorkspaceStrategy, - ) => { - const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + ) { + const { messages } = yield* threads.getThreadRecords(input.threadId, ["messages"], { + messageIds: [run.userMessageId], + }); + const message = messages.find((candidate) => candidate.id === run.userMessageId); const reuse = workspacePreparation.type === "worktree" && - projection.thread.worktreePath !== null && - projection.thread.branch !== null + thread.worktreePath !== null && + thread.branch !== null ? { strategy: { type: "existing_worktree" as const, - worktreePath: projection.thread.worktreePath, - branch: projection.thread.branch, + worktreePath: thread.worktreePath, + branch: thread.branch, }, reusedWorktree: { baseRef: workspacePreparation.baseRef }, } : null; - return schedulePreparation( + return yield* schedulePreparation( { commandId: input.commandId, - projectId: projection.thread.projectId, + projectId: thread.projectId, workspaceStrategy: reuse?.strategy ?? workspacePreparation, ...(reuse === null ? {} : { reusedWorktree: reuse.reusedWorktree }), ...(message === undefined @@ -1137,7 +1143,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation, prepareRun }); }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 0470bfde2a54..6137d68a77a6 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -396,10 +396,15 @@ export interface ThreadManagementServiceShape { ProjectionRecords, ThreadManagementProjectionLoadError | ThreadManagementThreadNotFoundError >; - readonly getShellSnapshot: (options?: { - readonly location?: "active" | "archive"; - }) => Effect.Effect; + readonly getShellSnapshot: ( + options?: Pick< + ShellSnapshotOptions, + "location" | "projectId" | "unsettledOnly" | "worktreePath" | "threadIds" + >, + ) => Effect.Effect; readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"]; + readonly countThreads: Orchestrator.OrchestratorV2["Service"]["countThreads"]; + readonly listActiveShellIndex: Orchestrator.OrchestratorV2["Service"]["listActiveShellIndex"]; readonly listOlderSettledThreads: Orchestrator.OrchestratorV2["Service"]["listOlderSettledThreads"]; readonly readShellSnapshot: ( options?: Pick, @@ -1378,6 +1383,8 @@ const make = Effect.gen(function* () { getProjectThreadRecords, getShellSnapshot: orchestrator.getShellSnapshot, listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds, + countThreads: orchestrator.countThreads, + listActiveShellIndex: orchestrator.listActiveShellIndex, listOlderSettledThreads: orchestrator.listOlderSettledThreads, readShellSnapshot: orchestrator.readShellSnapshot, getThreadShell: orchestrator.getThreadShell, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 5627a6dc37aa..5f380c7e0ace 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -179,6 +179,128 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => + Effect.scoped( + Effect.gen(function* () { + const parent = { + ...threadShell("parent-thread"), + branch: "feature/parent", + worktreePath: "/workspace/parent", + }; + const child: OrchestrationV2ThreadShell = { + ...threadShell("child-thread"), + branch: "t3/reply-ok", + worktreePath: "/workspace/child", + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent", + }, + }; + const project: OrchestrationProjectShell = { + id: parent.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const domainEvents = yield* Queue.unbounded(); + const sweeps = yield* Queue.unbounded(); + const childLookup = yield* Deferred.make(); + const lookups: Array<{ readonly branch: string; readonly refresh: boolean }> = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.fromQueue(domainEvents), + getShellSnapshot: () => + Queue.offer(sweeps, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [parent, child], + archivedThreads: [], + }), + ), + getThreadEventSequence: () => Effect.succeed(1), + getThreadShell: (threadId) => + Effect.succeed([parent, child].find((thread) => thread.id === threadId) ?? null), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input, options) => + Effect.gen(function* () { + const refresh = options?.refresh ?? false; + lookups.push({ branch: input.branch, refresh }); + if (input.branch === child.branch && refresh) { + yield* Deferred.succeed(childLookup, undefined); + } + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(true), + shouldRunOpportunisticWork: Effect.succeed(true), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({}), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + + // Startup backfill sweep, then one periodic sweep. + yield* Queue.take(sweeps); + yield* service.drain; + yield* TestClock.adjust("1 minute"); + yield* Queue.take(sweeps); + yield* service.drain; + const sweepLookups = lookups.map((lookup) => lookup.branch).toSorted(); + + // The child's own turn still discovers its pull request. + yield* Queue.offer(domainEvents, { + type: "checkpoint.captured", + threadId: child.id, + } as OrchestrationV2DomainEvent); + yield* Deferred.await(childLookup); + yield* service.drain; + + expect(sweepLookups).toEqual([parent.branch, parent.branch]); + expect(lookups.slice(sweepLookups.length)).toEqual([ + { branch: child.branch, refresh: true }, + ]); + }).pipe(Effect.provide(dependencies)); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 7b3c09ba15b8..0db9feac8587 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -139,9 +139,16 @@ export const make = Effect.gen(function* () { readThreadSnapshot(request), projectStore.listShells(), ]); + // Subagent children refresh after their own runs; sweeping them costs a lookup each per minute. + const snapshotThreads = + request.threadId === null + ? threadSnapshot.threads.filter( + (thread) => thread.lineage.relationshipToParent !== "subagent", + ) + : threadSnapshot.threads; const projects = new Map(projectShells.map((project) => [project.id, project])); if (request.backfill) { - for (const thread of threadSnapshot.threads) { + for (const thread of snapshotThreads) { if ( (thread.settledOverride === "settled" || thread.settledAt !== null) && thread.branchPullRequest == null @@ -151,13 +158,13 @@ export const make = Effect.gen(function* () { } } const visibleThreadIds = new Set( - threadSnapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + snapshotThreads.filter((thread) => thread.branch !== null).map((thread) => thread.id), ); const checkedIds = request.threadId === null ? [...pendingBackfill.keys()] : [request.threadId]; for (const threadId of checkedIds) { if (!visibleThreadIds.has(threadId)) pendingBackfill.delete(threadId); } - const threads = threadSnapshot.threads.filter( + const threads = snapshotThreads.filter( (thread) => thread.archivedAt === null && ((thread.settledOverride !== "settled" && thread.settledAt === null) || diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef0..80ee5c32d615 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -10,7 +10,10 @@ import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { + capProjectionCheckpointFilesForWire, + projectThreadProjectionForWire, +} from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; @@ -44,10 +47,9 @@ export function boundedSnapshotResponseFields(input: { readonly compactIds: boolean; }) { const { bounded } = input; + const capped = capProjectionCheckpointFilesForWire(bounded.projection); // Dedupe keeps shared items one object, which the compaction's identity check needs. - const projection = input.dedupeBodies - ? dedupeSnapshotBodies(bounded.projection) - : bounded.projection; + const projection = input.dedupeBodies ? dedupeSnapshotBodies(capped) : capped; const compactProjection = input.compactTurnItems ? omitLocalVisibleTurnItems(projection) : null; return { projection: compactProjection ?? projection, diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index d449aeb933db..0d63d832f0ca 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -1,4 +1,7 @@ import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, MessageId, ContextHandoffId, ProviderThreadId, @@ -381,6 +384,46 @@ describe("live body deduper", () => { ]); }); + it("caps a large checkpoint's files for the wire and still dedupes its item", () => { + const files = Array.from({ length: 250 }, (_, index) => ({ + path: `vendor/file-${index}.ts`, + kind: "modified", + additions: 2, + deletions: 1, + })); + const checkpointId = CheckpointId.make("checkpoint-large"); + const scopeId = CheckpointScopeId.make("scope-large"); + const captured = event(1, { + type: "checkpoint.captured", + payload: { + id: checkpointId, + threadId: base.threadId, + scopeId, + runId: null, + nodeId: NodeId.make("node-large"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: null, + ref: CheckpointRef.make("refs/t3/checkpoints/large"), + status: "ready", + files, + capturedAt: at, + }, + }); + const item = event(2, { + type: "turn-item.updated", + payload: { ...base, type: "checkpoint", checkpointId, scopeId, files }, + }); + const dedupe = makeLiveBodyDeduper(); + const [record, card] = [captured, item].map((streamed) => + dedupe({ ...streamed, event: projectDomainEventForWire(streamed.event) }), + ); + + const filesTotal = { count: 250, additions: 500, deletions: 250 }; + expect(payloadOf(record!)).toMatchObject({ files: files.slice(0, 200), filesTotal }); + expect(payloadOf(card!)).toMatchObject({ files: [], filesOmitted: true, filesTotal }); + }); + it("ships a reply whole when its message arrives after it", () => { const first = reply(1, "a"); expect([first, message(2, "a")].map(makeLiveBodyDeduper())[0]).toBe(first); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 6b66f1b91f20..2181b7feec5f 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -21,6 +21,8 @@ import { toolOutputIndicatesFailure, } from "@t3tools/shared/toolOutput"; +// A checkpoint over vendored or generated trees can list 15k+ files; the card needs a prefix. +const MAX_WIRE_CHECKPOINT_FILES = 200; const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; const MAX_ON_DEMAND_BYTES = 256 * 1024; @@ -96,8 +98,59 @@ function summarizeDynamicValue(value: unknown): unknown { }; } +type CheckpointFiles = Pick; + +// Same input, same output object: snapshot compaction relies on shared item identity. +const cappedCheckpointFiles = new WeakMap(); + +function capCheckpointFiles(value: T): T { + if (value.files.length <= MAX_WIRE_CHECKPOINT_FILES || value.filesTotal !== undefined) { + return value; + } + const cached = cappedCheckpointFiles.get(value); + if (cached !== undefined) return cached as T; + let additions = 0; + let deletions = 0; + for (const file of value.files) { + additions += file.additions; + deletions += file.deletions; + } + const capped = { + ...value, + files: value.files.slice(0, MAX_WIRE_CHECKPOINT_FILES), + filesTotal: { count: value.files.length, additions, deletions }, + }; + cappedCheckpointFiles.set(value, capped); + return capped; +} + +/** Idempotent; bounded snapshots that skip `projectThreadProjectionForWire` still need it. */ +export function capProjectionCheckpointFilesForWire( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const checkpoints = projection.checkpoints.map(capCheckpointFiles); + const turnItems = projection.turnItems.map(capCheckpointItemFiles); + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + const item = capCheckpointItemFiles(row.item); + return item === row.item ? row : { ...row, item }; + }); + const unchanged = (next: ReadonlyArray, previous: ReadonlyArray) => + next.every((value, index) => value === previous[index]); + return unchanged(checkpoints, projection.checkpoints) && + unchanged(turnItems, projection.turnItems) && + unchanged(visibleTurnItems, projection.visibleTurnItems) + ? projection + : { ...projection, checkpoints, turnItems, visibleTurnItems }; +} + +function capCheckpointItemFiles(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + return item.type === "checkpoint" ? capCheckpointFiles(item) : item; +} + export function projectTurnItemForWire(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { + case "checkpoint": + return capCheckpointFiles(item); case "handoff": { const { summary: _summary, ...projected } = item; return projected; @@ -241,6 +294,7 @@ export function projectThreadProjectionForWire( }; return { ...projection, + checkpoints: projection.checkpoints.map(capCheckpointFiles), contextHandoffs: projection.contextHandoffs.map(projectContextHandoffForWire), turnItems: projection.turnItems.map(project), visibleTurnItems: projection.visibleTurnItems.map((row) => ({ @@ -257,7 +311,9 @@ export function projectDomainEventForWire( ? { ...event, payload: projectTurnItemForWire(event.payload) } : event.type === "context-handoff.updated" ? { ...event, payload: projectContextHandoffForWire(event.payload) } - : event; + : event.type === "checkpoint.captured" + ? { ...event, payload: capCheckpointFiles(event.payload) } + : event; } const LIVE_DEDUPE_MEMORY = 32; diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 61ff54d0c794..c2c67eb04d9d 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -3,7 +3,6 @@ import { EnvironmentHttpApi, type OrchestrationProjectShell, } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; import * as Predicate from "effect/Predicate"; @@ -32,11 +31,7 @@ import { } from "./threadHistoryPaging.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProjectStore from "./ProjectStore.ts"; -import { - buildActiveShellSnapshot, - loadShellSnapshotParts, - shellSnapshotForSubscriber, -} from "./ShellStream.ts"; +import { loadSubscriberShellSnapshot } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; import { projectThreadProjectionForWire } from "./WireProjection.ts"; @@ -84,19 +79,19 @@ export const layer = HttpApiBuilder.group( readonly omitIdleSubagents: boolean; readonly omitOlderSettled: boolean; }) { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projectStore.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const { snapshot } = shellSnapshotForSubscriber(base, { - ...options, - now: yield* DateTime.now, + const { snapshot } = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projectStore.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options, }); - const projects = yield* enrichProjectShells(base.projects); + const projects = yield* enrichProjectShells(snapshot.projects); return { ...snapshot, projects }; }, ); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index df19b1b5ed2b..f10c2743d5a1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -40,6 +40,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -80,6 +81,7 @@ import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; @@ -5865,6 +5867,198 @@ it.layer(layerTest)("snooze queued runs", (it) => { ); }); +const SCRATCH_ROOT = "/tmp/runtime-layer-promotion-scratch"; +const SCRATCH_PROJECT_ID = ProjectId.make("runtime-layer-promotion-scratch"); +const layerScratchPromotionTest = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedSome(SCRATCH_ROOT), + isScratchProject: (projectId) => Effect.succeed(projectId === SCRATCH_PROJECT_ID), + namedProjectsRoot: "/projects", + }), + ), + Layer.provide(NodeServices.layer), + Layer.provideMerge(layerProjectDeletionTest), +); + +it.layer(layerScratchPromotionTest)("turning a No project thread into a project", (it) => { + const setup = Effect.fn(function* (name: string) { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const folder = `${SCRATCH_ROOT}/2026-01-01-${name}`; + if (Option.isNone(yield* projects.getById(SCRATCH_PROJECT_ID))) { + yield* projects.create({ + commandId: CommandId.make("promotion-scratch-create"), + projectId: SCRATCH_PROJECT_ID, + title: "No project", + workspaceRoot: SCRATCH_ROOT, + }); + } + const createThread = (suffix: string, worktreePath: string) => + orchestrator + .dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`promotion-${name}-${suffix}-create`), + threadId: ThreadId.make(`promotion-${name}-${suffix}`), + projectId: SCRATCH_PROJECT_ID, + title: `${name} ${suffix}`, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath, + }) + .pipe(Effect.as(ThreadId.make(`promotion-${name}-${suffix}`))); + return { folder, createThread }; + }); + const shellOf = (threadId: ThreadId) => + Orchestrator.OrchestratorV2.pipe( + Effect.flatMap((orchestrator) => orchestrator.getThreadShell(threadId)), + ); + + it.effect("moves every thread in the folder and its bound tasks into one project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const tasks = yield* ScheduledTaskService.ScheduledTaskService; + const { folder, createThread } = yield* setup("moves"); + const threadId = yield* createThread("main", folder); + const siblingId = yield* createThread("sibling", folder); + const otherId = yield* createThread("other", `${SCRATCH_ROOT}/2026-01-01-elsewhere`); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("promotion-moves-sibling-archive"), + threadId: siblingId, + }); + const { task } = yield* tasks.upsert({ + id: ScheduledTaskId.make("promotion-moves-bound"), + title: "Bound", + prompt: "Check in", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: SCRATCH_PROJECT_ID, + threadId: siblingId, + workspaceStrategy: { type: "root" }, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "web", + }); + + const { projectId } = yield* promotion.promote({ threadId }); + + const project = yield* projects.getById(projectId); + assert.equal(Option.getOrUndefined(project)?.workspaceRoot, folder); + assert.equal(Option.getOrUndefined(project)?.title, "moves main"); + for (const id of [threadId, siblingId]) { + const shell = yield* shellOf(id); + assert.equal(shell?.projectId, projectId); + assert.isNull(shell?.worktreePath); + } + assert.equal((yield* shellOf(otherId))?.projectId, SCRATCH_PROJECT_ID); + const saved = (yield* tasks.list()).tasks.find((candidate) => candidate.id === task.id); + assert.equal(saved?.projectId, projectId); + assert.deepEqual(yield* promotion.promote({ threadId }), { projectId }); + }), + ); + + it.effect("changes nothing while a thread in the folder is busy", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("busy"); + const threadId = yield* createThread("idle", folder); + const busyId = yield* createThread("delegate", folder); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-busy-message"), + threadId: busyId, + messageId: MessageId.make("promotion-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal(error.message, '"busy delegate" is still working. Finish or stop it first.'); + assert.isTrue(Option.isNone(yield* projects.getByWorkspaceRoot(folder))); + assert.equal((yield* shellOf(threadId))?.projectId, SCRATCH_PROJECT_ID); + }), + ); + + it.effect("finishes a partial move from either thread", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { folder, createThread } = yield* setup("partial"); + const movedId = yield* createThread("moved", folder); + const leftId = yield* createThread("left", folder); + const earlier = yield* projects.create({ + commandId: CommandId.make("promotion-partial-project"), + projectId: ProjectId.make("promotion-partial-project"), + title: "Earlier", + workspaceRoot: folder, + }); + yield* orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make("promotion-partial-adopt"), + threadId: movedId, + projectId: earlier.id, + }); + + assert.deepEqual(yield* promotion.promote({ threadId: movedId }), { projectId: earlier.id }); + assert.equal((yield* shellOf(leftId))?.projectId, earlier.id); + }), + ); + + it.effect("refuses a thread outside No project", () => + Effect.gen(function* () { + const promotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make("promotion-regular-project"); + const threadId = ThreadId.make("promotion-regular-thread"); + yield* projects.create({ + commandId: CommandId.make("promotion-regular-project"), + projectId, + title: "Regular", + workspaceRoot: "/tmp/runtime-layer-promotion-regular", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("promotion-regular-thread-create"), + threadId, + projectId, + title: "Regular", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + + const error = yield* promotion.promote({ threadId }).pipe(Effect.flip); + + assert.equal( + error.message, + "Only a No project thread with its own folder can become a project.", + ); + }), + ); +}); + it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { it.effect("carries snooze state through the V2 shell projection", () => Effect.gen(function* () { @@ -5960,6 +6154,88 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.isNull(awakened.thread.snoozedAt); }), ); + + it.effect("moves a thread into a project rooted at its own folder", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const scratchProjectId = ProjectId.make("runtime-layer-adopt-scratch"); + const folderProjectId = ProjectId.make("runtime-layer-adopt-folder"); + const otherProjectId = ProjectId.make("runtime-layer-adopt-other"); + const folder = "/tmp/runtime-layer-adopt-scratch/2026-01-01-notes-a1b2c3d4"; + const createProject = (projectId: ProjectId, workspaceRoot: string) => + projects.create({ + commandId: CommandId.make(`${projectId}-create`), + projectId, + title: projectId, + workspaceRoot, + }); + const createThread = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-create`), + threadId, + projectId: scratchProjectId, + title: "Scratch work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: folder, + }); + const adopt = (threadId: ThreadId, projectId: ProjectId, suffix: string) => + orchestrator.dispatch({ + type: "thread.project.adopt-folder", + commandId: CommandId.make(`${threadId}-adopt-${suffix}`), + threadId, + projectId, + }); + yield* createProject(scratchProjectId, "/tmp/runtime-layer-adopt-scratch"); + yield* createProject(folderProjectId, folder); + yield* createProject(otherProjectId, "/tmp/runtime-layer-adopt-other"); + const idleThreadId = ThreadId.make("runtime-layer-adopt-idle"); + const busyThreadId = ThreadId.make("runtime-layer-adopt-busy"); + yield* createThread(idleThreadId); + yield* createThread(busyThreadId); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-adopt-busy-message"), + threadId: busyThreadId, + messageId: MessageId.make("runtime-layer-adopt-busy-message"), + text: "Keep this run active.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const otherFolder = yield* adopt(idleThreadId, otherProjectId, "other").pipe(Effect.flip); + assert.equal( + otherFolder.cause, + `Thread ${idleThreadId} does not work in /tmp/runtime-layer-adopt-other.`, + ); + const missing = yield* adopt(idleThreadId, ProjectId.make("missing"), "missing").pipe( + Effect.flip, + ); + assert.equal(missing.cause, `Thread ${idleThreadId} cannot move to missing project missing.`); + const busy = yield* adopt(busyThreadId, folderProjectId, "busy").pipe(Effect.flip); + assert.equal( + busy.cause, + `Thread ${busyThreadId} still has work in progress. Finish or stop it first.`, + ); + + yield* adopt(idleThreadId, folderProjectId, "folder"); + const shell = yield* orchestrator.getShellSnapshot(); + const moved = shell.threads.find((thread) => thread.id === idleThreadId); + assert.equal(moved?.projectId, folderProjectId); + assert.isNull(moved?.worktreePath); + const stayed = shell.threads.find((thread) => thread.id === busyThreadId); + assert.equal(stayed?.projectId, scratchProjectId); + }), + ); }); it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { @@ -6062,6 +6338,114 @@ it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => { assert.instanceOf(invalidVisit, Orchestrator.OrchestratorDispatchError); }), ); + + it.effect("marks unread before the user's last turn when an automatic compaction followed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-mark-unread-compacted"); + const turnCompletedAt = DateTime.makeUnsafe("2026-07-24T10:00:00.000Z"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Compacted thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const runs = [ + { text: "Continue", at: turnCompletedAt }, + { + text: "/compact", + idleCompaction: "beforeCacheExpires" as const, + at: DateTime.makeUnsafe("2026-07-24T11:00:00.000Z"), + }, + ]; + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make(`${threadId}:seed`), + events: runs.flatMap(({ at, ...message }, index) => { + const runId = RunId.make(`${threadId}:run-${index}`); + const messageId = MessageId.make(`${threadId}:message-${index}`); + return [ + { + id: EventId.make(`${messageId}:event`), + type: "message.updated" as const, + threadId, + occurredAt: at, + payload: { + ...message, + id: messageId, + threadId, + runId, + nodeId: null, + role: "user" as const, + attachments: [], + streaming: false, + createdBy: "user" as const, + creationSource: "web" as const, + createdAt: at, + updatedAt: at, + }, + }, + { + id: EventId.make(`${runId}:event`), + type: "run.created" as const, + threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: at, + payload: { + id: runId, + threadId, + ordinal: index + 1, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed" as const, + queuePosition: null, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }, + }, + ]; + }), + }); + + yield* orchestrator.dispatch({ + type: "thread.mark-unread", + commandId: CommandId.make(`${threadId}:mark-unread`), + threadId, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.lastVisitedAt, + DateTime.subtract(turnCompletedAt, { milliseconds: 1 }), + ); + const shell = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(shell!.latestRunCompletedAt, turnCompletedAt); + assert.deepEqual(shell!.lastVisitedAt, projection.thread.lastVisitedAt); + + yield* orchestrator.dispatch({ + type: "thread.visit", + commandId: CommandId.make(`${threadId}:visit`), + threadId, + visitedAt: DateTime.formatIso(turnCompletedAt), + }); + const revisited = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual(revisited!.lastVisitedAt, turnCompletedAt); + }), + ); }); it.layer(Layer.fresh(layerSharedApplicationDataPlaneTest))("thread opens", (it) => { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 53222d8fc074..27c26e8e41c0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -16,6 +16,7 @@ import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "../project/ScratchThreadPromotion.ts"; import * as CheckpointCaptureService from "./CheckpointCaptureService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; @@ -291,6 +292,16 @@ const layerScheduledTaskProvided = ScheduledTaskService.layer.pipe( ), ), ); +const layerScratchThreadPromotionProvided = ScratchThreadPromotion.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerProjectService, + layerManagedProjectFoldersProvided, + layerThreadManagementProvided, + layerScheduledTaskProvided, + ), + ), +); const layerProviderContinuationWorkerProvided = ProviderContinuationService.layer.pipe( Layer.provide( Layer.mergeAll( @@ -364,6 +375,7 @@ export const layerProduction = Layer.mergeAll( layerManagedProjectFoldersProvided, layerThreadLaunchProvided, layerScheduledTaskProvided, + layerScratchThreadPromotionProvided, layerSecretRequestsProvided, GuestShareService.layer.pipe( Layer.provide(Layer.merge(layerOrchestratorProvided, layerCommandOutputHubProvided)), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 26c836ed5a72..de8b2e0d850c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -40,6 +40,7 @@ import * as ProviderAuthService from "../../provider/ProviderAuthService.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import * as ProviderContinuationService from "../ProviderContinuationService.ts"; import * as ProviderEventIngestor from "../ProviderEventIngestor.ts"; +import * as ProviderInstanceRegistry from "../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRuntimeRecoveryService from "../ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "../ProviderSessionManager.ts"; import * as CommandOutputRecorder from "./CommandOutputRecorder.ts"; @@ -350,6 +351,10 @@ export function layerWithRegistry( const layerProviderEventIngestorProvided = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + // @effect-diagnostics-next-line effectSucceedWithVoid:off - Optional instances require undefined rather than void. + getInstance: () => Effect.succeed(undefined), + }), layerStores, layerEventSinkProvided, IdAllocator.layer, diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 4e2d572bd96b..8754bdbd2edc 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -48,6 +48,7 @@ export const ConsumeAuthPairingLinkInput = Schema.Struct({ credential: Schema.String, proofKeyThumbprint: Schema.NullOr(Schema.String), requestedScopes: Schema.optionalKey(AuthEnvironmentScopes), + requiredScopes: Schema.optionalKey(AuthEnvironmentScopes), consumedAt: Schema.DateTimeUtcFromString, now: Schema.DateTimeUtcFromString, }); @@ -161,7 +162,14 @@ export const make = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, Result: AuthPairingLinkRawDbRow, - execute: ({ credential, proofKeyThumbprint, requestedScopes, consumedAt, now }) => + execute: ({ + credential, + proofKeyThumbprint, + requestedScopes, + requiredScopes, + consumedAt, + now, + }) => sql` UPDATE auth_pairing_links SET consumed_at = ${consumedAt} @@ -180,6 +188,13 @@ export const make = Effect.gen(function* () { SELECT value FROM json_each(auth_pairing_links.scopes) ) )) + AND NOT EXISTS ( + SELECT 1 + FROM json_each(${JSON.stringify(requiredScopes ?? [])}) AS required + WHERE required.value NOT IN ( + SELECT value FROM json_each(auth_pairing_links.scopes) + ) + ) RETURNING id AS "id", credential AS "credential", diff --git a/apps/server/src/persistence/ForkMigrations.test.ts b/apps/server/src/persistence/ForkMigrations.test.ts index b2da964203c0..c1b82cccaa2a 100644 --- a/apps/server/src/persistence/ForkMigrations.test.ts +++ b/apps/server/src/persistence/ForkMigrations.test.ts @@ -106,6 +106,7 @@ const expectedUpstreamAbove51: ReadonlyArray = [ [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]; /** Upstream migrations newer than any fork-era ledger, which a moved ledger still runs. */ const upstreamAfterForkEra = expectedUpstreamAbove51.filter(([id]) => id > 56); diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f..54e4a711aa8a 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -1,7 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as SqlClient from "effect/sql/SqlClient"; import { migrationManifest, runMigrations } from "./Migrations.ts"; @@ -123,3 +126,20 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); }); + +// The npm launcher reads the highest migration id from these file names. +it.effect("registers every migration file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(import.meta.dirname, "Migrations"); + const files = (yield* fs.readDirectory(dir)).flatMap((name) => { + const match = /^(\d+)_(\w+)\.ts$/.exec(name); + return match ? [[Number(match[1]), match[2]] as const] : []; + }); + assert.deepStrictEqual( + files.toSorted(([a], [b]) => a - b), + migrationManifest, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 55692eb2c3e8..7e362be5bde1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -76,6 +76,7 @@ import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -151,6 +152,7 @@ const migrationEntries = [ [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ActiveTurnItemIndexes", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts new file mode 100644 index 000000000000..d9759bca6d82 --- /dev/null +++ b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Recovery and provider-thread reads filter live items by this exact predicate; without + // these, each read walked every historical item in the thread. + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_ordinal_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, turn_item_id) + WHERE status IN ('pending', 'running', 'waiting') + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_active_run_idx + ON orchestration_v2_projection_turn_items(thread_id, run_id) + WHERE status IN ('pending', 'running', 'waiting') + `; +}); diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts index 45b28834a4ad..940d62651433 100644 --- a/apps/server/src/persistence/Sqlite.test.ts +++ b/apps/server/src/persistence/Sqlite.test.ts @@ -5,12 +5,57 @@ import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "./Sqlite.ts"; +// Linux names an open, already-deleted file " (deleted)" in /proc/self/fd. +// SQLite names its temp files "etilqs_*" and, without TMPDIR, puts them in +// /var/tmp while os.tmpdir() is /tmp, so match the name, not the directory. +const openDeletedTempFiles = () => + NodeFS.readdirSync("/proc/self/fd").flatMap((fd) => { + try { + const target = NodeFS.readlinkSync(`/proc/self/fd/${fd}`); + return target.includes("/etilqs_") && target.endsWith(" (deleted)") ? [target] : []; + } catch { + return []; + } + }); + +it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "keeps a write transaction's statement journal off the temp directory", + () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-temp-")); + const persistence = SqlitePersistence.layerFromPath( + NodePath.join(tempDir, "state.sqlite"), + ).pipe(Layer.provide(NodeServices.layer)); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE journal_probe(id INTEGER PRIMARY KEY, payload TEXT NOT NULL)`; + for (let id = 0; id < 64; id++) { + yield* sql`INSERT INTO journal_probe VALUES (${id}, ${"x".repeat(4096)})`; + } + const before = new Set(openDeletedTempFiles()); + const opened = yield* sql.withTransaction( + Effect.gen(function* () { + // A multi-row update can fail halfway, so SQLite journals every page + // it changes, here well past the 64 KiB it holds in memory by default. + yield* sql`UPDATE journal_probe SET payload = payload || 'y'`; + return openDeletedTempFiles().filter((target) => !before.has(target)); + }), + ); + assert.deepEqual(opened, []); + }).pipe( + Effect.provide(persistence), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); + }, +); + it.effect("drops planner statistics a database already has", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-stats-")); const dbPath = NodePath.join(tempDir, "state.sqlite"); diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts index f2f29815a714..4d6c934344b5 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -28,6 +28,9 @@ const layerSetup = Layer.effectDiscard( yield* sql`PRAGMA synchronous = NORMAL;`; // macOS fsync misses drive caches; F_FULLFSYNC flushes them at checkpoints and is ignored elsewhere. yield* sql`PRAGMA checkpoint_fullfsync = ON;`; + // Keep statement journals and sort spills off the main thread's disk path. + // Large statements and open savepoints can increase peak memory use. + yield* sql`PRAGMA temp_store = MEMORY;`; // PASSIVE checkpoints retain peak WAL size until the last connection closes. yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); diff --git a/apps/server/src/persistence/databaseMigrationHead.ts b/apps/server/src/persistence/databaseMigrationHead.ts new file mode 100644 index 000000000000..046039a70341 --- /dev/null +++ b/apps/server/src/persistence/databaseMigrationHead.ts @@ -0,0 +1,49 @@ +// @effect-diagnostics nodeBuiltinImport:off - read synchronously by the preflight subprocess and at startup. +import * as NodeFS from "node:fs"; +import * as NodeSqlite from "node:sqlite"; + +import { migrationManifest } from "./Migrations.ts"; + +export const BUILD_MIGRATION_HEAD = Math.max(...migrationManifest.map(([id]) => id)); + +/** Migrations the database records, read-only. A missing or new database records none; one that + * exists but cannot be read is null, since its history is unknown. */ +export function recordedMigrations( + databasePath: string, +): ReadonlyArray<{ readonly id: number; readonly name: string }> | null { + let db: NodeSqlite.DatabaseSync; + try { + db = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + } catch { + return NodeFS.existsSync(databasePath) ? null : []; + } + try { + const hasTable = db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'", + ) + .get(); + if (hasTable === undefined) return []; + return db + .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id") + .all() + .map((row) => ({ id: Number(row.migration_id), name: String(row.name) })); + } catch { + return null; + } finally { + try { + db.close(); + } catch { + // A read-only handle has nothing to flush; the result above already stands. + } + } +} + +/** Highest migration the database holds once this build's startup migrations have run. A newer + * build may have recorded more than this build knows. Undefined when the database is unreadable. */ +export function databaseMigrationHead(databasePath: string): number | undefined { + const recorded = recordedMigrations(databasePath); + return recorded === null + ? undefined + : Math.max(BUILD_MIGRATION_HEAD, ...recorded.map(({ id }) => id)); +} diff --git a/apps/server/src/persistence/initializeV2Database.test.ts b/apps/server/src/persistence/initializeV2Database.test.ts index 561cd72ea9f7..ec8ded1cab5a 100644 --- a/apps/server/src/persistence/initializeV2Database.test.ts +++ b/apps/server/src/persistence/initializeV2Database.test.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -182,3 +183,28 @@ it.effect("starts fresh without V1 and never imports over existing V2 state", () Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), ); }); + +it.effect("removes copies left by a killed start, but not one that is still running", () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-v2-abandoned-")); + const killed = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid; + const running = `.v2-import-${process.pid}-Kq81Zx`; + const unrelated = ".v2-import-not-a-copy"; + return Effect.gen(function* () { + for (const name of [`.v2-import-${killed}-Wm3nPa`, ".v2-import-Q3xZ9a", running]) { + NodeFS.mkdirSync(NodePath.join(directory, name)); + NodeFS.writeFileSync(NodePath.join(directory, name, "snapshot.sqlite"), "partial copy"); + } + NodeFS.mkdirSync(NodePath.join(directory, unrelated)); + const destinationPath = NodePath.join(directory, "statev2.sqlite"); + NodeFS.writeFileSync(destinationPath, "existing V2 database"); + yield* initializeV2Database(destinationPath); + assert.deepEqual( + NodeFS.readdirSync(directory).sort(), + [running, unrelated, "statev2.sqlite"].sort(), + ); + assert.equal(NodeFS.readFileSync(destinationPath, "utf8"), "existing V2 database"); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true }))), + ); +}); diff --git a/apps/server/src/persistence/initializeV2Database.ts b/apps/server/src/persistence/initializeV2Database.ts index 755907b5076f..4f6d8d969c3e 100644 --- a/apps/server/src/persistence/initializeV2Database.ts +++ b/apps/server/src/persistence/initializeV2Database.ts @@ -14,6 +14,33 @@ export class V2DatabaseImportError extends Schema.TaggedError { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +// Legacy names have no PID; newer copies stay while their process is alive. +const removeAbandonedCopies = Effect.fn("removeAbandonedCopies")(function* (directory: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const copy = /^\.v2-import-(?:(\d+)-)?[A-Za-z0-9]{6}$/.exec(entry); + if (copy === null || (copy[1] !== undefined && isProcessAlive(Number(copy[1])))) continue; + yield* fs.remove(path.join(directory, entry), { recursive: true }); + } +}); + /** Its copied legacy tables remain the source for lazy transcript import. */ export const initializeV2Database = Effect.fn("initializeV2Database")(function* ( destinationPath: string, @@ -22,12 +49,13 @@ export const initializeV2Database = Effect.fn("initializeV2Database")(function* const path = yield* Path.Path; const directory = path.dirname(destinationPath); const sourcePath = path.join(directory, "state.sqlite"); + yield* removeAbandonedCopies(directory).pipe(Effect.ignore({ log: true })); yield* Effect.gen(function* () { if (yield* fs.exists(destinationPath)) return; if (!(yield* fs.exists(sourcePath))) return; const temporaryDirectory = yield* fs.makeTempDirectoryScoped({ directory, - prefix: ".v2-import-", + prefix: `.v2-import-${process.pid}-`, }); const snapshotPath = path.join(temporaryDirectory, "snapshot.sqlite"); yield* Effect.tryPromise(async () => { diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 91e40578091a..c08062c09d1a 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -52,6 +52,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -147,6 +148,7 @@ describe("V2 preview upgrade", () => { [70, "PeerLinks"], [71, "PeerLinkFence"], [72, "WaitingLinkRequestIndex"], + [73, "ActiveTurnItemIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/preview/PreviewBrowser.test.ts b/apps/server/src/preview/PreviewBrowser.test.ts index 0cb1665d4f3d..a5c241229a37 100644 --- a/apps/server/src/preview/PreviewBrowser.test.ts +++ b/apps/server/src/preview/PreviewBrowser.test.ts @@ -73,6 +73,7 @@ const browserArchive = makeZip([ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( options: { + readonly version?: string; readonly archive?: Buffer; readonly sha256?: string; readonly body?: Stream.Stream; @@ -93,7 +94,7 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( release: options.unsupported ? null : { - version: "1.2.3", + version: options.version ?? "1.2.3", platform: "fixture", url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip", bytes: archive.byteLength, @@ -121,10 +122,11 @@ const makeHarness = Effect.fn("test.makePreviewBrowser")(function* ( }); it.layer(NodeServices.layer)("PreviewBrowser", (it) => { - it.effect("installs a verified download with its file modes and then reuses it", () => + it.effect.each(["0.8.0", "1.2.3"])("retains another version when installing %s", (current) => Effect.gen(function* () { - const { browser, fs, path, installRoot, requests } = yield* makeHarness(); - yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true }); + const { browser, fs, path, installRoot, requests } = yield* makeHarness({ version: current }); + yield* fs.makeDirectory(path.join(installRoot, "0.9.0"), { recursive: true }); + yield* fs.makeDirectory(path.join(installRoot, "0.10.0")); const active = path.join(installRoot, ".install-other"); const abandoned = path.join(installRoot, ".install-abandoned"); yield* fs.makeDirectory(active); @@ -135,7 +137,7 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { const executable = yield* browser.executable; - const version = path.join(installRoot, "1.2.3"); + const version = path.join(installRoot, current); expect(executable).toBe(path.join(version, executableName)); expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings"); if (hostPlatform !== "win32") { @@ -145,10 +147,9 @@ it.layer(NodeServices.layer)("PreviewBrowser", (it) => { expect(yield* mode("libEGL.so")).toBe(0o755); expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644); } - expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([ - ".install-other", - "1.2.3", - ]); + expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual( + [".install-other", "0.10.0", current].toSorted(), + ); expect(yield* browser.executable).toBe(executable); expect(yield* browser.installed).toEqual(Option.some(executable)); expect(requests).toHaveLength(1); diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index d13b495ef5c2..ea5539aad5e0 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -321,11 +321,14 @@ export const makePreviewBrowser = Effect.fn("PreviewBrowser.make")(function* ( yield* fs.remove(destination, { recursive: true, force: true }); yield* fs.rename(unpacked, destination); }).pipe(Effect.scoped); - // Shared homes may have another server installing; preserve staging directories touched within the last hour. + // Shared homes may still use the newest other version or staging directories touched within the last hour. const entries = yield* fs.readDirectory(installRoot); + const previous = entries + .filter((name) => name !== release.version && !name.startsWith(".")) + .toSorted((left, right) => right.localeCompare(left, "en", { numeric: true }))[0]; const now = yield* Clock.currentTimeMillis; yield* Effect.forEach( - entries.filter((name) => name !== release.version), + entries.filter((name) => name !== release.version && name !== previous), (name) => Effect.gen(function* () { const target = path.join(installRoot, name); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 1e816f31b77d..3262c555bcf8 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -10,11 +10,14 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { EDITORS } from "@t3tools/contracts"; +import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; @@ -137,6 +140,79 @@ it.effect.skipIf(windowsHost)( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +// Windows drive letters cannot resolve the POSIX editor stub. +it.effect.skipIf(windowsHost)( + "a Linux editor launch without a display takes the desktop session's display from the systemd user manager", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const codiumPath = path.join(binDir, "codium"); + yield* fileSystem.writeFileString(codiumPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(codiumPath, 0o755); + + const launchEnv = (sessionManager: MockSpawnResult) => + Effect.gen(function* () { + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ editor: "vscodium", cwd: "/workspace" }); + yield* launcher.launchBrowser("https://example.com"); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { PATH: binDir }, + onSpawn: (command) => spawned.push(command), + spawnResult: (command) => + command.command === "busctl" ? sessionManager : undefined, + }), + ), + Effect.provideService(HostProcess.Environment, { PATH: binDir }), + ); + // Only editors borrow the session display; a browser must not open on that screen. + assert.deepEqual(spawned.find((command) => command.command === "xdg-open")?.options.env, { + PATH: binDir, + }); + return spawned.find((command) => command.command === "codium")?.options.env; + }); + + assert.deepEqual( + yield* launchEnv({ + stdout: JSON.stringify({ + type: "as", + data: [ + "DISPLAY=:0", + "HOME=/home/user", + "WAYLAND_DISPLAY=wayland-1", + "XAUTHORITY=/home/user/auth files/cookie", + ], + }), + }), + { + PATH: binDir, + DISPLAY: ":0", + WAYLAND_DISPLAY: "wayland-1", + // Raw, not the $'...' quoting `systemctl show-environment` would print. + XAUTHORITY: "/home/user/auth files/cookie", + }, + ); + assert.deepEqual(yield* launchEnv({ stdout: "invalid JSON" }), { PATH: binDir }); + assert.deepEqual(yield* launchEnv({ stdout: '{"data":[42]}' }), { PATH: binDir }); + assert.deepEqual( + yield* launchEnv({ + spawnError: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + { PATH: binDir }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("launches an installed editor with platform-safe arguments", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -335,6 +411,106 @@ for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12 ); } +for (const { layout, pathNames, fallbackNames, expected } of [ + { layout: "PATH", pathNames: ["zed", "zeditor"], fallbackNames: [], expected: "zeditor" }, + { + layout: "mixed locations", + pathNames: ["zed"], + fallbackNames: ["zeditor"], + expected: "fallback", + }, + { + layout: "fallback directory", + pathNames: [], + fallbackNames: ["zed", "zeditor"], + expected: "fallback", + }, +]) { + it.effect.skipIf(windowsHost)(`prefers the Zed editor on Linux with ${layout}`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-linux-" }); + const binDir = path.join(home, "bin"); + const fallbackDir = path.join(home, ".local/bin"); + for (const [dir, names] of [ + [binDir, pathNames], + [fallbackDir, fallbackNames], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + for (const name of names) { + yield* fs.writeFileString(path.join(dir, name), "#!/bin/sh\n"); + yield* fs.chmod(path.join(dir, name), 0o755); + } + } + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + assert.include(yield* launcher.resolveAvailableEditors(), "zed"); + yield* launcher.launchEditor({ editor: "zed", cwd: "/workspace" }); + }).pipe( + Effect.provide( + layerTest({ + platform: "linux", + env: { HOME: home, PATH: binDir }, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + assert.ok(spawned); + assert.equal( + spawned.command, + expected === "fallback" ? path.join(fallbackDir, "zeditor") : expected, + ); + assert.deepEqual(spawned.args, ["/workspace"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + +it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-fallback-" }); + const localBin = path.join(home, ".local/bin"); + const systemBin = path.join(home, "system-bin"); + for (const [dir, name] of [ + [localBin, "zed"], + [systemBin, "zeditor"], + ] as const) { + yield* fs.makeDirectory(dir, { recursive: true }); + const executable = path.join(dir, name); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + } + const editor = EDITORS.find((editor) => editor.id === "zed")!; + const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(Path.Path, { + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), + }), + ); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(systemBin, "zeditor"), baseArgs: [] }), + ); + yield* fs.remove(path.join(systemBin, "zeditor")); + assert.deepEqual( + yield* resolve, + Option.some({ command: path.join(localBin, "zed"), baseArgs: [] }), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.each([ { editor: "webstorm", installPath: "Applications/WebStorm 2026.2.app/Contents/MacOS/webstorm" }, { editor: "idea", installPath: "Applications/IntelliJ IDEA 2026.1.4.app/Contents/MacOS/idea" }, diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index b8c0911a520d..6a2eb145162e 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -31,6 +31,7 @@ import * as Layer from "effect/Layer"; 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"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -672,6 +673,56 @@ const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* ( yield* launchEditorProcess(launch); }); +const GRAPHICAL_SESSION_ENV_KEYS: ReadonlySet = new Set([ + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", +]); + +// The user manager's environment over D-Bus. `systemctl show-environment` would +// shell-quote values with spaces as $'...', while this JSON carries them raw. +const decodeManagerEnvironment = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ data: Schema.Array(Schema.String) })), +); + +// Boot services miss the desktop environment imported later into the systemd user manager. +const readGraphicalSessionEnv = Effect.fn("externalLauncher.readGraphicalSessionEnv")( + function* () { + const hostEnv = yield* HostProcess.Environment; + if ((yield* HostProcess.Platform) !== "linux" || hasGraphicalLinuxSession(hostEnv)) { + return {}; + } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = yield* spawner.string( + ChildProcess.make( + "busctl", + [ + "--user", + "--json=short", + "get-property", + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "Environment", + ], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + const { data } = yield* decodeManagerEnvironment(output); + return Object.fromEntries( + data.flatMap((line) => { + const separator = line.indexOf("="); + const key = line.slice(0, separator); + return separator > 0 && GRAPHICAL_SESSION_ENV_KEYS.has(key) + ? [[key, line.slice(separator + 1)]] + : []; + }), + ); + }, + Effect.timeout("2 seconds"), + Effect.orElseSucceed((): NodeJS.ProcessEnv => ({})), +); + const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( launch: ProcessLaunch, onError: (cause: unknown) => ExternalLauncherError, @@ -681,7 +732,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, - env, + env: { ...env, ...launch.options.env }, extendEnv: false, }); @@ -735,6 +786,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu options: { detached: launch.detached ?? true, shell: spawnCommand.shell, + env: yield* readGraphicalSessionEnv(), stdin: "ignore", stdout: "ignore", stderr: "ignore", diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 18004510baa4..3ba57db6d7e5 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -457,15 +457,13 @@ export const make = Effect.gen(function* () { ) { const { projectId } = input; const snapshot = yield* threadProjections - .getShellSnapshot() + .getShellSnapshot({ projectId }) .pipe( Effect.mapError( (cause) => new ProjectOperationError({ operation: "list-threads", projectId, cause }), ), ); - const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => thread.projectId === projectId, - ); + const projectThreads = [...snapshot.threads, ...snapshot.archivedThreads]; if (projectThreads.length > 0 && input.force !== true) { return yield* new ProjectNotEmptyError({ projectId }); } diff --git a/apps/server/src/project/ScratchThreadPromotion.ts b/apps/server/src/project/ScratchThreadPromotion.ts new file mode 100644 index 000000000000..671a480a9583 --- /dev/null +++ b/apps/server/src/project/ScratchThreadPromotion.ts @@ -0,0 +1,161 @@ +import { + CommandId, + type OrchestrationV2ThreadShell, + ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { threadHasUnfinishedWork } from "../orchestration-v2/Orchestrator.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as ManagedProjectFolders from "./ManagedProjectFolders.ts"; +import * as ProjectService from "./ProjectService.ts"; + +export class ScratchThreadPromotionError extends Schema.TaggedError()( + "ScratchThreadPromotionError", + { + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export class ScratchThreadPromotion extends Context.Service< + ScratchThreadPromotion, + { + /** + * Turns a No project thread's folder into a project, in place, and moves every + * thread working in that folder into it. Running it again finishes a partial move. + */ + readonly promote: (input: { + readonly threadId: ThreadId; + }) => Effect.Effect<{ readonly projectId: ProjectId }, ScratchThreadPromotionError>; + } +>()("t3/project/ScratchThreadPromotion") {} + +const make = Effect.gen(function* () { + const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const projects = yield* ProjectService.ProjectService; + const threads = yield* ThreadManagementService.ThreadManagementService; + const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + + const fail = (reason: string) => (cause: unknown) => + new ScratchThreadPromotionError({ reason, cause }); + + const promote: ScratchThreadPromotion["Service"]["promote"] = Effect.fn( + "ScratchThreadPromotion.promote", + )(function* (input) { + const thread = yield* threads.getThreadShell(input.threadId).pipe( + Effect.mapError(fail("Could not read the thread.")), + Effect.filterOrFail( + (shell) => shell !== null, + () => new ScratchThreadPromotionError({ reason: "Thread not found." }), + ), + ); + const project = yield* projects + .getById(thread.projectId) + .pipe(Effect.mapError(fail("Could not read the thread's project."))); + const folder = thread.worktreePath ?? Option.getOrUndefined(project)?.workspaceRoot; + const scratchRoot = Option.getOrUndefined(yield* managedFolders.scratchRoot); + const isScratch = yield* managedFolders.isScratchProject(thread.projectId); + // A thread already rooted at its folder is a partial earlier promotion; finish it. + const alreadyPromoted = + Option.isSome(project) && project.value.workspaceRoot === folder && !isScratch; + if ( + folder === undefined || + scratchRoot === undefined || + path.dirname(folder) !== scratchRoot || + !(isScratch || alreadyPromoted) + ) { + return yield* new ScratchThreadPromotionError({ + reason: "Only a No project thread with its own folder can become a project.", + }); + } + + const snapshot = yield* Effect.flatten( + threads.readShellSnapshot({ worktreePath: folder }), + ).pipe(Effect.mapError(fail("Could not read the threads in this folder."))); + const moveSet = new Map(); + if (isScratch) moveSet.set(thread.id, thread); + for (const candidate of [...snapshot.threads, ...snapshot.archivedThreads]) { + if (yield* managedFolders.isScratchProject(candidate.projectId)) { + moveSet.set(candidate.id, candidate); + } + } + + for (const candidate of moveSet.values()) { + const records = yield* threads + .getThreadRecords(candidate.id, ["runs", "runtimeRequests"], { turnItemTypes: [] }) + .pipe(Effect.mapError(fail(`Could not read "${candidate.title}".`))); + if (threadHasUnfinishedWork(records)) { + return yield* new ScratchThreadPromotionError({ + reason: `"${candidate.title}" is still working. Finish or stop it first.`, + }); + } + } + + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const projectId = yield* createProject(thread.title, folder); + yield* scheduledTasks + .moveThreadTasks({ threadIds: [...moveSet.keys()], projectId }) + .pipe(Effect.mapError(fail("Could not move the thread's scheduled tasks."))); + for (const candidate of moveSet.values()) { + const commandId = CommandId.make(`scratch-promotion:${yield* randomId}`); + yield* threads + .dispatch({ + type: "thread.project.adopt-folder", + commandId, + threadId: candidate.id, + projectId, + }) + .pipe(Effect.mapError(fail(`Could not move "${candidate.title}". Try again.`))); + } + return { projectId }; + }), + ); + }); + + const randomId = crypto + .randomUUIDv4() + .pipe(Effect.mapError(fail("Could not create an id for the project."))); + + // bootstrap returns the project that already owns the folder, so retries reuse it. + const createProject = Effect.fn("ScratchThreadPromotion.createProject")(function* ( + title: string, + workspaceRoot: string, + ) { + const id = yield* randomId; + return yield* projects + .bootstrap({ + commandId: CommandId.make(`scratch-promotion-project:${id}`), + projectId: ProjectId.make(id), + title, + workspaceRoot, + }) + .pipe( + Effect.map(({ project }) => project.id), + // bootstrap reads before locking; racing creates return the winner in a conflict. + Effect.catchTags({ + ProjectConflictError: (conflict) => Effect.succeed(conflict.conflictingProjectId), + }), + Effect.mapError(fail("Could not create the project.")), + ); + }); + + return ScratchThreadPromotion.of({ promote }); +}); + +export const layer = Layer.effect(ScratchThreadPromotion, make); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 98f685889121..346e59486a9d 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -131,7 +131,10 @@ it.effect("agent context matches the committed budget", () => ), }; - expect(measured).toEqual(budget); + expect( + measured, + "Set agentContextBudget.json to the measured values. Before you cut text to fit, run the evals that cover it (evals/README.md).", + ).toEqual(budget); }).pipe( Effect.provide( McpHttpServer.layerTools.pipe( diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 3d590abda0bd..902e6508ca25 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -398,7 +398,6 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma }), ) : spawner, - clientInfo: { name: "t3-code", version: "0.0.0" }, }); const initialized = yield* runtime.initialize(); if ( diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index ade033551f65..1f852ad8bcc8 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -214,6 +214,69 @@ describe("Antigravity model catalog", () => { "gemini-pro-agent", ); }); + + it("labels models that share a display name with their IDs", () => { + const models = buildAntigravityModelsFromSession({ + configOptions: [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash", + options: [ + { value: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + { value: "gemini-3.8-flash-preview", name: "Gemini 3.8 Flash" }, + ], + }, + ], + }); + expect(models.map((model) => model.name)).toEqual([ + "Gemini 3.8 Flash", + "Gemini 3.8 Flash (gemini-3.8-flash-preview)", + ]); + }); + + it("offers each model the thinking levels it reported when selected", () => { + const thinking = (currentValue: string) => + ({ + id: "thinking_level", + name: "Thinking Level", + category: "thought_level", + type: "select", + currentValue, + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }) satisfies EffectAcpSchema.SessionConfigOption; + const models = buildAntigravityModelsFromSession( + { + configOptions: [ + { + ...modelConfig, + currentValue: "gemini", + options: [ + { value: "gemini", name: "Gemini" }, + { value: "claude", name: "Claude" }, + { value: "plain", name: "Plain" }, + { value: "unseen", name: "Unseen" }, + ], + }, + thinking("high"), + ], + }, + new Map([ + ["claude", thinking("low")], + ["plain", null], + ]), + ); + const thinkingOf = (slug: string) => + models.find((model) => model.slug === slug)?.capabilities?.optionDescriptors?.[0]; + + expect(thinkingOf("gemini")).toMatchObject({ id: "thinking_level", currentValue: "high" }); + expect(thinkingOf("claude")).toMatchObject({ id: "thinking_level", currentValue: "low" }); + expect(thinkingOf("plain")).toBeUndefined(); + expect(thinkingOf("unseen")).toMatchObject({ id: "thinking_level" }); + expect(thinkingOf("unseen")).not.toHaveProperty("currentValue"); + }); }); it.layer(layerTest)("Antigravity provider snapshots", (it) => { diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index c6809c357713..db7d22a45e0c 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -20,6 +20,7 @@ import type * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { acpProviderOptionDescriptors } from "@t3tools/provider-acp/server/sessionConfig"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { makeManualOnlyProviderMaintenanceCapabilities, @@ -32,6 +33,8 @@ import { } from "@t3tools/provider-core/server/snapshotProbe"; import { resolveUsageLimitsAfterProbe } from "@t3tools/provider-core/server/usageLimits"; +import { antigravityThinkingConfig } from "./acp/AntigravityAcpSupport.ts"; + const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); const MAX_WORKSPACE_SNAPSHOTS = 32; const HEALTH_CHECK_TIMEOUT = "90 seconds"; @@ -44,8 +47,24 @@ type SessionSetupResult = Pick< "configOptions" | "models" >; +type ThinkingConfig = NonNullable>; + +/** Thinking options each model reported when selected; `null` means it has none. */ +export type AntigravityThinkingLevels = ReadonlyMap; + +function thinkingCapabilities(config: ThinkingConfig | null | undefined, observed: boolean) { + const descriptor = config + ? acpProviderOptionDescriptors({ configOptions: [config], modeState: undefined })[0] + : undefined; + if (descriptor?.type !== "select") return EMPTY_MODEL_CAPABILITIES; + // Another model's selected level is not this model's default. + const { currentValue: _currentValue, ...unobserved } = descriptor; + return createModelCapabilities({ optionDescriptors: [observed ? descriptor : unobserved] }); +} + export function buildAntigravityModelsFromSession( setup: SessionSetupResult, + thinkingLevels: AntigravityThinkingLevels = new Map(), ): ReadonlyArray { const config = setup.configOptions?.find( (option) => option.id === "model" || option.category === "model", @@ -61,19 +80,30 @@ export function buildAntigravityModelsFromSession( name: model.name, })) ?? []) : []; + const currentThinking = antigravityThinkingConfig(setup.configOptions); const seen = new Set(); + // The agent can list two IDs under one name; label the later ones so the picker tells them apart. + const names = new Set(); return entries.flatMap((entry): ServerProviderModel[] => { if (!entry.value.trim() || seen.has(entry.value)) return []; seen.add(entry.value); + const baseName = entry.name.trim() ? entry.name : entry.value; + const name = names.has(baseName) ? `${baseName} (${entry.value})` : baseName; + names.add(baseName); + const observed = + entry.value === currentValue ? (currentThinking ?? null) : thinkingLevels.get(entry.value); return [ { slug: entry.value, - name: entry.name.trim() ? entry.name : entry.value, + name, isCustom: false, ...(entry.value === currentValue ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } : {}), - capabilities: EMPTY_MODEL_CAPABILITIES, + capabilities: + observed === undefined + ? thinkingCapabilities(currentThinking, false) + : thinkingCapabilities(observed, true), }, ]; }); @@ -162,6 +192,17 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); + const thinkingLevels = new Map(); + const observeThinkingLevels = ( + configOptions: ReadonlyArray | null | undefined, + ) => { + const model = configOptions?.find( + (option) => option.id === "model" || option.category === "model", + ); + if (model?.type === "select" && model.currentValue) { + thinkingLevels.set(model.currentValue, antigravityThinkingConfig(configOptions) ?? null); + } + }; const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), ); @@ -267,6 +308,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func cwd?: string, ) { if (!settings.enabled) return; + observeThinkingLevels(started.sessionSetupResult.configOptions); const before = yield* SubscriptionRef.get(metadata); const supportsTextGeneration = yield* options.supportsTextGeneration; const usageLimits = @@ -301,7 +343,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func published: draft.usageLimits, probed: usageLimits ?? draft.usageLimits, }), - models: buildAntigravityModelsFromSession(started.sessionSetupResult), + models: buildAntigravityModelsFromSession(started.sessionSetupResult, thinkingLevels), supportsTextGeneration, ...(cwd ? { @@ -324,7 +366,8 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( configOptions: ReadonlyArray, ) { - const models = buildAntigravityModelsFromSession({ configOptions }); + observeThinkingLevels(configOptions); + const models = buildAntigravityModelsFromSession({ configOptions }, thinkingLevels); yield* SubscriptionRef.update(metadata, (state) => { if (state.draft.auth.status !== "authenticated") return state; return { ...state, draft: { ...state.draft, models } }; @@ -391,6 +434,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }) satisfies AntigravityProviderState, ); discoveredSkills.clear(); + thinkingLevels.clear(); }); const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fd5725c6cd79..f1f39e104b44 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -38,6 +38,8 @@ import { type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { + antigravityModelOptions, + antigravityThinkingConfig, makeAntigravityAcpRuntime, type AntigravityAcpRuntimeInput, } from "../acp/AntigravityAcpSupport.ts"; @@ -294,7 +296,6 @@ export const AntigravityDriver: ProviderDriver< ); const runtime = yield* makeRuntime({ cwd, - clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" }, mcpServers: [], ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), }); @@ -313,7 +314,10 @@ export const AntigravityDriver: ProviderDriver< const publishCatalog = Effect.fn("AntigravityDriver.publishCatalog")(function* ( started: AcpSessionRuntime.AcpSessionRuntimeStartResult, - runtime: Pick, + runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getEvents" | "drainEvents" | "setModel" + >, ) { yield* provider.onSessionStarted(started); yield* Stream.runForEach(runtime.getEvents(), (event) => { @@ -327,6 +331,14 @@ export const AntigravityDriver: ProviderDriver< ? provider.onAvailableCommands(event.availableCommands) : Effect.void; }).pipe(Effect.forkScoped); + // Thinking levels differ per model and are reported only for the selected one. + const configOptions = started.sessionSetupResult.configOptions ?? []; + const current = configOptions.find((option) => option.id === "model")?.currentValue; + if (antigravityThinkingConfig(configOptions) !== undefined && typeof current === "string") { + for (const model of [...antigravityModelOptions(configOptions), { value: current }]) { + yield* runtime.setModel(model.value).pipe(Effect.ignore); + } + } yield* runtime.drainEvents; }, Effect.scoped); @@ -439,7 +451,6 @@ export const AntigravityDriver: ProviderDriver< makeRuntime: (cwd) => makeRuntime({ cwd, - clientInfo: { name: "t3-code-text", version: "0.0.0" }, mcpServers: [], }), }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 7249a4063e17..5dd2c411cb1f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -303,6 +303,7 @@ export const ClaudeDriver: ProviderDriver resolveMaintenance().pipe( Effect.flatMap((maintenanceCapabilities) => diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 2897cc0b8428..860280aa91e8 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -358,6 +358,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { '--add-dir "extra skills"', '--add-dir="extra skills"', `--add-dir missing --add-dir "${extraDir}"`, + `--add-dir "${extraDir}" --add-dir missing`, + `--add-dir missing "${extraDir}"`, ]) { const settings = { homePath: configDir, launchArgs }; const skills = yield* discoverClaudeSkills(settings, workspace); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 56c2ff19bebd..bc68e42b76a2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { parse as parseYamlDocument } from "yaml"; @@ -427,6 +427,26 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); +// Claude's --add-dir is repeatable and variadic; parseCliArgs keeps only one value. +export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray { + const directories: string[] = []; + let collecting = false; + for (const token of tokenizeCliArgs(launchArgs)) { + if (token === "--") break; + if (token === "--add-dir") { + collecting = true; + } else if (token.startsWith("--add-dir=")) { + directories.push(token.slice("--add-dir=".length)); + collecting = false; + } else if (token.startsWith("-")) { + collecting = false; + } else if (collecting) { + directories.push(token); + } + } + return directories; +} + /** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, @@ -442,22 +462,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), - ...(additionalDirectory && (cwd || path.isAbsolute(additionalDirectory)) - ? [ - { - directory: path.join( - path.resolve(cwd ?? ".", additionalDirectory), - ".claude", - "skills", - ), - scope: "project" as const, - }, - ] - : []), + ...claudeLaunchArgDirectories(config.launchArgs) + .filter((directory) => directory && (cwd || path.isAbsolute(directory))) + .map((directory) => ({ + directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), + scope: "project" as const, + })), ...(yield* discoverPluginSkillRoots( configDirPath, cwd, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 90e37cc66712..0aacca303e6a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -377,6 +377,107 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "runs the standalone updater against the home that holds a shared install", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + // One standalone install, linked onto PATH and used by two account homes. + // `ฤฐ` gets longer when lowercased, so the home must not be sliced from a + // lowercased copy of the path. + const installHome = NodePath.join(tempDir, "codex-ฤฐnstall"); + const realBinaryPath = NodePath.join( + installHome, + "packages", + "standalone", + "releases", + "0.162.1-x86_64-unknown-linux-musl", + "bin", + "codex", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const updates = yield* Effect.forEach(["codex-p", "codex-vm"], (account) => + Effect.gen(function* () { + const homePath = NodePath.join(tempDir, `home-${account}`); + yield* fs.makeDirectory(homePath, { recursive: true }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(account), + displayName: account, + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath }, + }); + return (yield* instance.snapshot.resolveMaintenance()).update; + }), + ); + + for (const update of updates) { + expect(update).toMatchObject({ + command: `${binaryPath} update`, + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: installHome }, + }); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + + it.effect.skipIf(windowsHost)("derives the standalone update home from either command path", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-update-home-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const linkedInstallHome = NodePath.join(tempDir, "install"); + const fixtures = [ + { binary: "codex", realPath: "/packages/standalone/bin/codex", home: "/" }, + { binary: "codex", realPath: "C:\\Packages\\Standalone\\bin\\codex.exe", home: "C:\\" }, + // The standalone tree itself is a symlink, so only the resolved path names its home. + { + binary: NodePath.join("install", "packages", "standalone", "bin", "codex"), + realPath: "/disk/codex/releases/0.163.0/bin/codex", + home: linkedInstallHome, + }, + ]; + + for (const fixture of fixtures) { + const binaryPath = NodePath.join(tempDir, fixture.binary); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-update-home"), + displayName: "Codex update home", + enabled: false, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: tempDir }, + }).pipe( + // Virtualize only the executable target: these trees do not belong to this test. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + realPath: (path) => + path === binaryPath ? Effect.succeed(fixture.realPath) : fs.realPath(path), + }), + ); + expect((yield* instance.snapshot.resolveMaintenance()).update?.env).toEqual({ + CODEX_HOME: fixture.home, + }); + } + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index da6cc512809d..140740ff21b6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -38,7 +38,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -61,20 +60,36 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -function isCodexStandaloneCommandPath(commandPath: string): boolean { - return normalizeCommandPath(commandPath).includes("/packages/standalone/"); +const CODEX_STANDALONE_HOME_PATTERN = /^(.*)\/packages\/standalone\//is; + +function codexStandaloneHome(commandPath: string): string | null { + // Slash replacement preserves indices; Unicode lowercasing can change the path length. + const match = CODEX_STANDALONE_HOME_PATTERN.exec(commandPath.replaceAll("\\", "/")); + if (!match) { + return null; + } + const home = match[1]!; + // Keep root separators: `C:` is drive-relative, and an empty home is not `/`. + const isRoot = home === "" || /^[a-z]:$/i.test(home); + return commandPath.slice(0, home.length + (isRoot ? 1 : 0)); } -/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ +// Standalone updates locate their install through CODEX_HOME. Account and auth-overlay +// homes can share an executable, so only the updater uses the executable's owning home. function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", nativeUpdate: { args: ["update"], - isCommandPath: isCodexStandaloneCommandPath, + isCommandPath: (commandPath) => codexStandaloneHome(commandPath) !== null, requiresCommandPath: true, - env: { CODEX_HOME: sharedHomePath }, + env: (context) => ({ + CODEX_HOME: + codexStandaloneHome(context.realCommandPath) ?? + codexStandaloneHome(context.resolvedCommandPath) ?? + sharedHomePath, + }), }, }); } diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index bd48ee767ee4..8e0eed1371c5 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -38,8 +38,18 @@ function makeModelRuntime( if (failure) return yield* failure; selections.push(model); }); + const setConfigOption = Effect.fn("AntigravityAcpSupportTest.setConfigOption")(function* ( + configId: string, + value: string | boolean, + options?: { readonly allowUnlistedValue?: boolean }, + ) { + selections.push( + `${configId}=${String(value)}${options?.allowUnlistedValue === true ? " (unlisted)" : ""}`, + ); + return { configOptions }; + }); return { - runtime: { getConfigOptions: Effect.succeed(configOptions), setModel }, + runtime: { getConfigOptions: Effect.succeed(configOptions), setModel, setConfigOption }, selections, }; } @@ -137,6 +147,30 @@ describe("applyAntigravityAcpModelSelection", () => { expect(selections).toEqual(["gemini-saved"]); }), ); + + it.effect("passes a model ID saved on runtime 1.3 to the agent as its 1.4 family", () => + Effect.gen(function* () { + // The cold-resume model has no thinking option, so the level is unknown locally. + const { runtime, selections } = makeModelRuntime([ + { + ...modelConfig, + currentValue: "gpt-oss-120b-medium", + options: [ + { value: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + { value: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)" }, + ], + }, + ]); + const model = yield* applyAntigravityAcpModelSelection({ + runtime, + model: "gemini-3.8-flash-low", + mapError: (cause) => cause, + }); + + expect(model).toBe("gemini-3.8-flash"); + expect(selections).toEqual(["model=gemini-3.8-flash-low (unlisted)"]); + }), + ); }); const imageAttachment = { diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index ac64c9da2a46..a57fc08f9b7a 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -31,6 +31,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "authenticateEagerly" | "cancelBehavior" | "clientCapabilities" + | "clientInfo" | "onStderr" | "resumeMethod" | "transformSessionUpdate" @@ -38,7 +39,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< > { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; - readonly clientFileSystem?: boolean; + readonly clientFileReads?: boolean; readonly authMethod?: AntigravityAuthMethod; } @@ -56,11 +57,11 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")( authenticateEagerly: true, resumeMethod: "resume", cancelBehavior: "wait-for-prompt", + // The agent offers non-Gemini models only to allowlisted clients such as Zed. + clientInfo: { name: "zed", version: "0.0.0" }, clientCapabilities: { - fs: { - readTextFile: input.clientFileSystem === true, - writeTextFile: input.clientFileSystem === true, - }, + // Advertising writes swaps the agent's diff edits for whole-file client_edit_file. + fs: { readTextFile: input.clientFileReads === true, writeTextFile: false }, terminal: false, }, transformStdout: makeAntigravityStdoutTransform( @@ -91,7 +92,7 @@ export function antigravityPermissionMode(runtimeMode: RuntimeMode): string { } } -function antigravityModelOptions( +export function antigravityModelOptions( configOptions: ReadonlyArray, ) { const model = configOptions.find((option) => option.id === "model"); @@ -99,6 +100,28 @@ function antigravityModelOptions( return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)); } +/** Runtime 1.4 reports thinking as its own option; 1.3 encoded it in the model ID. */ +export function antigravityThinkingConfig( + configOptions: ReadonlyArray | null | undefined, +) { + const option = configOptions?.find((candidate) => candidate.category === "thought_level"); + return option?.type === "select" ? option : undefined; +} + +/** + * Runtime 1.4 lists `gemini-3.8-flash` where 1.3 listed `gemini-3.8-flash-high`. The agent + * still accepts the old IDs, so pass them through; the current model may have no thinking option. + */ +function legacyAntigravityFamily( + configOptions: ReadonlyArray, + model: string, +) { + return antigravityModelOptions(configOptions) + .map((option) => option.value) + .filter((family) => model.startsWith(`${family}-`)) + .toSorted((left, right) => right.length - left.length)[0]; +} + function resolveAntigravityModel(input: { readonly configOptions: ReadonlyArray; readonly model: string | null | undefined; @@ -108,7 +131,9 @@ function resolveAntigravityModel(input: { const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined; if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model; const options = antigravityModelOptions(input.configOptions); - return input.defaultModel && options.some((option) => option.value === input.defaultModel) + return input.defaultModel && + (options.some((option) => option.value === input.defaultModel) || + legacyAntigravityFamily(input.configOptions, input.defaultModel) !== undefined) ? input.defaultModel : current; } @@ -118,7 +143,7 @@ export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpM function* (input: { readonly runtime: Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], - "getConfigOptions" | "setModel" + "getConfigOptions" | "setModel" | "setConfigOption" >; readonly model: string | null | undefined; readonly defaultModel?: string | undefined; @@ -135,6 +160,15 @@ export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpM const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL; if (resolved === undefined || (!explicit && resolved === current)) return current; const options = antigravityModelOptions(configOptions); + const legacyFamily = options.some((option) => option.value === resolved) + ? undefined + : legacyAntigravityFamily(configOptions, resolved); + if (legacyFamily !== undefined) { + yield* input.runtime + .setConfigOption("model", resolved, { allowUnlistedValue: true }) + .pipe(Effect.mapError(input.mapError)); + return legacyFamily; + } if (!options.some((option) => option.value === resolved)) { return yield* Effect.fail( input.mapError( diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index 4efbc28a80ce..c6079234d916 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -5,7 +5,6 @@ import type * as Path from "effect/Path"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -/** The agent requests permission for every write; check only path containment here. */ const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { @@ -98,21 +97,3 @@ export const readAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.r return { content: lines.slice(start, end).join("\n") }; }, ); - -export const writeAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.writeTextFile")( - function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly allowedRoots: ReadonlyArray; - readonly request: EffectAcpSchema.WriteTextFileRequest; - }): Effect.fn.Return { - const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path }); - yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe( - Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)), - Effect.mapError(() => - EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`), - ), - ); - return {}; - }, -); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 192c8b43ffb1..909de58cb755 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -13,6 +13,7 @@ import { normalizeAntigravitySessionUpdate, normalizeAntigravityToolCall, sanitizeAntigravityToolPayload, + makeAntigravityReadRepair, } from "./AntigravityProtocol.ts"; import { mergeToolCallState, @@ -24,7 +25,12 @@ const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); describe("native Antigravity subagent tools", () => { it("recognizes only native invocation titles and excludes MCP tools", () => { const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} }; - for (const title of ["Running start_subagent", "Run start_subagent?"]) { + for (const title of [ + "Running start_subagent", + "Run start_subagent?", + "Running invoke_subagent", + "Run invoke_subagent?", + ]) { expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent"); expect( classifyAntigravitySubagentToolCall( @@ -408,3 +414,82 @@ describe("Antigravity tool results", () => { expect(serialized).not.toContain("inline-data-url"); }); }); + +describe("Antigravity served reads", () => { + const flush = (reads: ReturnType, toolCallId: string) => + reads.repair({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "failed", + rawOutput: "Tool execution failed", + }, + }).update; + + it("credits a read T3 served before the agent announced it", () => { + const reads = makeAntigravityReadRepair(); + reads.attempted("/repo/a.ts", true); + const announced = reads.repair({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "call_late", + title: "Running client_view_file", + kind: "read", + status: "in_progress", + locations: [{ path: "/repo/a.ts" }], + }, + }); + expect(announced.update).toMatchObject({ status: "completed" }); + expect(flush(reads, "call_late")).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call_late", + status: "completed", + }); + }); + + it("forgets an earlier turn's unannounced served read", () => { + const reads = makeAntigravityReadRepair(); + reads.attempted("/repo/a.ts", true); + reads.reset(); + reads.repair({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "call_refused", + title: "Running client_view_file", + kind: "read", + status: "in_progress", + locations: [{ path: "/repo/a.ts" }], + }, + }); + reads.attempted("/repo/a.ts", false); + expect(flush(reads, "call_refused")).toMatchObject({ status: "failed" }); + }); + + it("drops an earlier turn's unanswered read so a new turn's served read gets the credit", () => { + const reads = makeAntigravityReadRepair(); + const announce = (toolCallId: string) => + reads.repair({ + sessionId: "session-1", + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Running client_view_file", + kind: "read", + status: "in_progress", + locations: [{ path: "/repo/a.ts" }], + }, + }); + announce("call_abandoned"); + reads.reset(); + announce("call_current"); + reads.attempted("/repo/a.ts", true); + expect(flush(reads, "call_current")).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "call_current", + status: "completed", + }); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 7e2f24a31d3b..a91d45164caa 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -216,6 +216,87 @@ export function sanitizeAntigravityToolPayload(payload: unknown): unknown { } const isJsonRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); +const ANTIGRAVITY_CLIENT_READ_TITLES = new Set([ + "Running client_view_file", + "Run client_view_file?", +]); + +function antigravityClientReadPath(update: { + readonly title?: string | null; + readonly locations?: ReadonlyArray<{ readonly path: string }> | null; + readonly rawInput?: unknown; +}): string | undefined { + if (!update.title || !ANTIGRAVITY_CLIENT_READ_TITLES.has(update.title)) return undefined; + const target = + update.locations?.[0]?.path ?? + (Predicate.isObject(update.rawInput) ? update.rawInput.absolute_path : undefined); + return typeof target === "string" && target ? target : undefined; +} + +/** + * The agent never completes a subagent's `client_view_file` call, then fails every open call + * with the next unrelated step error. Report reads T3 served as completed instead. + */ +export function makeAntigravityReadRepair() { + type Outcome = "served" | "refused"; + const openReads = new Map< + string, + { readonly path: string; readonly sessionId: string; outcome?: Outcome } + >(); + // The agent runs a client tool in a background task, so its request can precede the announcement. + const earlyOutcomes: Array<{ readonly path: string; readonly outcome: Outcome }> = []; + return { + /** Earlier turns' rows are already settled, and their unanswered reads must not take credit. */ + reset: () => { + openReads.clear(); + earlyOutcomes.length = 0; + }, + /** + * Each `fs/read_text_file` request answers the oldest open read of its path, in order. + * Returns the call a served read completes, so T3 can report it without waiting. + */ + attempted: (path: string, served: boolean) => { + const outcome = served ? "served" : "refused"; + for (const [toolCallId, read] of openReads) { + if (read.path !== path || read.outcome !== undefined) continue; + read.outcome = outcome; + return served ? { sessionId: read.sessionId, toolCallId } : undefined; + } + earlyOutcomes.push({ path, outcome }); + return undefined; + }, + repair: ( + notification: EffectAcpSchema.SessionNotification, + ): EffectAcpSchema.SessionNotification => { + const update = notification.update; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return notification; + } + const path = antigravityClientReadPath(update); + if (path !== undefined && !openReads.has(update.toolCallId)) { + const early = earlyOutcomes.findIndex((entry) => entry.path === path); + const outcome = early === -1 ? undefined : earlyOutcomes.splice(early, 1)[0]?.outcome; + const sessionId = notification.sessionId; + openReads.set( + update.toolCallId, + outcome === undefined ? { path, sessionId } : { path, sessionId, outcome }, + ); + } + const read = openReads.get(update.toolCallId); + if (update.status === "pending" || update.status === "in_progress") { + return read?.outcome === "served" + ? { ...notification, update: { ...update, status: "completed" } } + : notification; + } + if (update.status !== "completed" && update.status !== "failed") return notification; + openReads.delete(update.toolCallId); + if (update.status === "completed" || read?.outcome !== "served") return notification; + const { rawOutput: _stepError, ...rest } = update; + return { ...notification, update: { ...rest, status: "completed" } }; + }, + }; +} + export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, ): EffectAcpSchema.SessionNotification { @@ -324,14 +405,22 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo }; } -/** ACP 1.1.1 reports subagent calls as ordinary tools without child IDs or models. */ +const ANTIGRAVITY_SUBAGENT_TITLES = new Set([ + "Running start_subagent", + "Run start_subagent?", + "Running invoke_subagent", + "Run invoke_subagent?", +]); + +/** The ACP agent reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, ): "subagent" | "mcp" | undefined { if ( (toolCall.kind !== undefined && toolCall.kind !== "other") || - (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?") + toolCall.title === undefined || + !ANTIGRAVITY_SUBAGENT_TITLES.has(toolCall.title) ) return undefined; const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 6eec1580c387..292bde36fdf9 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 52518 + "mcpToolListJsonBytes": 52618 } diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index f00185865b05..17173760d5e3 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,5 @@ -const ANTIGRAVITY_RELEASE_VERSION = "1.3.0"; +// 1.4.2 through 1.4.4 write skills/system/run-workflow/SKILL.md into every session workspace. +const ANTIGRAVITY_RELEASE_VERSION = "1.4.1"; export interface AntigravityReleaseAsset { readonly version: string; @@ -20,66 +21,66 @@ const releaseAssets = new Map([ "darwin-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-arm64.zip", - sha256: "7cd97045f7b4fe81175a107cdf16f9c51484e3c78a5162cae415338bb6aa5b88", - archiveBytes: 111_456_962, - executable: { name: "agy_acp_server.par", bytes: 278_535_456 }, - harness: { name: "localharness_external", bytes: 118_611_392 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-arm64.zip", + sha256: "f9bdd68291ae78c38d2141e63bc86b84e6cc7b9f60f71652c68435a7943deb17", + archiveBytes: 112_737_434, + executable: { name: "agy_acp_server.par", bytes: 280_996_576 }, + harness: { name: "localharness_external", bytes: 120_246_928 }, }, ], [ "darwin-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.3.0-darwin-x86_64.zip", - sha256: "bb23956b89984bf5d354af2c3725e6c57f0cc1b7228e77a0e91c9c2bc1d47646", - archiveBytes: 117_245_544, - executable: { name: "agy_acp_server.par", bytes: 282_840_688 }, - harness: { name: "localharness_external", bytes: 124_175_392 }, + url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.1-darwin-x86_64.zip", + sha256: "27e31f096874704f14167b07ef9e84cc77134615ee41d024f7b1c957add85307", + archiveBytes: 118_349_036, + executable: { name: "agy_acp_server.par", bytes: 285_186_624 }, + harness: { name: "localharness_external", bytes: 124_910_048 }, }, ], [ "linux-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-x86_64.zip", - sha256: "9fb60956af0a9d76220a4db91ca9ac88e2a2372ad68f985ab5fceace6b825b96", - archiveBytes: 333_727_150, - executable: { name: "agy_acp_server.par", bytes: 926_533_965 }, - harness: { name: "localharness_external", bytes: 130_388_040 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-x86_64.zip", + sha256: "2e37012ceb2a5c7fc7309b6e6596234fb637ee3c62a5199f65d4cbe777871384", + archiveBytes: 336_170_339, + executable: { name: "agy_acp_server.par", bytes: 930_826_753 }, + harness: { name: "localharness_external", bytes: 131_187_512 }, }, ], [ "linux-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.3.0-linux-arm64.zip", - sha256: "500b0bc0fb858e88f4df404d4cedf80bf9298c178291e39e383d6c50b111cbdf", - archiveBytes: 321_690_363, - executable: { name: "agy_acp_server.par", bytes: 930_848_992 }, - harness: { name: "localharness_external", bytes: 123_224_968 }, + url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.1-linux-arm64.zip", + sha256: "609336a1c97545487e3ddd55dbd153dc0f40f4e6534486cc1c5f4c0ebb85c2da", + archiveBytes: 321_922_507, + executable: { name: "agy_acp_server.par", bytes: 933_416_796 }, + harness: { name: "localharness_external", bytes: 124_895_000 }, }, ], [ "win32-x64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-x86_64.zip", - sha256: "65215e0688681fa3116e048a9eab27ef53af1bbd6f3da3f1c52bd4911d8b17f9", - archiveBytes: 124_509_787, - executable: { name: "agy_acp_server.exe", bytes: 81_437_336 }, - harness: { name: "localharness_external.exe", bytes: 145_548_952 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-x86_64.zip", + sha256: "5f5b7d0328df17c128b2be338d4567b0d24e56ace8196e88794fec37d1372554", + archiveBytes: 129_599_308, + executable: { name: "agy_acp_server.exe", bytes: 86_098_784 }, + harness: { name: "localharness_external.exe", bytes: 146_036_888 }, }, ], [ "win32-arm64", { version: ANTIGRAVITY_RELEASE_VERSION, - url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.3.0-windows-arm64.zip", - sha256: "4a0f469720e9beb9438a979f543fdbfad5022ebe0992c052c590bd78b3144ca3", - archiveBytes: 124_654_803, - executable: { name: "agy_acp_server.exe", bytes: 85_893_472 }, - harness: { name: "localharness_external.exe", bytes: 135_640_216 }, + url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-1.4.1-windows-arm64.zip", + sha256: "24c52bbc12aae20f55eb53d373669412d95a9c44fd59be4ea4e355918b91fbf7", + archiveBytes: 132_507_344, + executable: { name: "agy_acp_server.exe", bytes: 93_197_744 }, + harness: { name: "localharness_external.exe", bytes: 136_970_904 }, }, ], ]); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index b42932fcc666..87ed61776ea9 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -75,8 +75,12 @@ { "driver": "antigravity", "t3CodeRange": ">=0.0.42", - "recommendedRange": "=1.1.1", - "ranges": [{ "range": "=1.1.1", "status": "supported" }] + "recommendedRange": "=1.4.1", + "ranges": [ + { "range": "=1.4.1", "status": "supported" }, + { "range": "=1.3.0", "status": "supported" }, + { "range": "=1.1.1", "status": "supported" } + ] }, { "driver": "pi", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index e3d4f672016c..12c541fbd776 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -21,7 +21,12 @@ import { ProviderCompatibilityPolicy, resolveProviderCompatibility, } from "./providerCompatibility.ts"; +import { resolveAntigravityReleaseAsset } from "./antigravityRelease.ts"; +import bundledManifest from "./model-manifest.json" with { type: "json" }; +const decodeCompatibilityPolicies = Schema.decodeUnknownSync( + Schema.Array(ProviderCompatibilityPolicy), +); const driver = ProviderDriverKind.make("codex"); const policy: ProviderCompatibilityPolicy = { driver, @@ -127,6 +132,19 @@ describe("provider compatibility", () => { } }); + it("supports the managed Antigravity runtime in the bundled manifest", () => { + const policies = decodeCompatibilityPolicies(bundledManifest.compatibility); + const release = resolveAntigravityReleaseAsset("darwin", "arm64"); + assert.strictEqual( + resolveProviderCompatibility( + policies, + ProviderDriverKind.make("antigravity"), + `agy_acp_server_${release?.version}`, + )?.status, + "supported", + ); + }); + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { for (const [version, expected] of [ ["0.9.9", "broken"], diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 4b2d67738c20..6c6c8215aa31 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -116,6 +116,65 @@ function hostedChangeRequest(body: string, additions = 1) { }; } +it.effect("reads maintainer edits and changes them through the update command", () => + Effect.gen(function* () { + let enabled = true; + const provider = fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => + Effect.succeed({ ...hostedChangeRequest("Body"), maintainerCanModify: enabled }), + updateChangeRequest: (input) => + Effect.sync(() => { + assert.strictEqual(input.title, undefined); + assert.strictEqual(input.body, undefined); + enabled = input.maintainerCanModify!; + }), + }); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + { + ...provider, + capabilities: { + ...provider.capabilities, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, + }, + }, + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, true); + yield* service.update({ ...ref, maintainerCanModify: false }); + assert.strictEqual((yield* service.detail(ref)).maintainerCanModify, false); + }), +); + +it.effect("refuses maintainer edits on a host without the capability", () => + Effect.gen(function* () { + let writes = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + updateChangeRequest: () => + Effect.sync(() => { + writes += 1; + }), + }), + ], + }); + const result = yield* service + .update({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + maintainerCanModify: false, + }) + .pipe(Effect.flip); + assert.strictEqual(result._tag, "PullRequestOperationError"); + assert.strictEqual(writes, 0); + }), +); + it.effect("does not wait for an in-flight detail read to display a preview", () => Effect.gen(function* () { const detailStarted = yield* Deferred.make(); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 5ee9b6274408..854d182f155e 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1753,6 +1753,9 @@ export const make = Effect.gen(function* () { ...(changeRequest.autoMergeMethod === undefined ? {} : { autoMergeMethod: changeRequest.autoMergeMethod }), + ...(changeRequest.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: changeRequest.maintainerCanModify }), ...(changeRequest.workflowApprovalsRequired === undefined ? {} : { workflowApprovalsRequired: changeRequest.workflowApprovalsRequired }), @@ -2138,7 +2141,22 @@ export const make = Effect.gen(function* () { }), ); } - if (input.title === undefined && input.body === undefined) { + if ( + input.maintainerCanModify !== undefined && + project.api.capabilities.edit?.maintainerEdits !== true + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "This host does not support maintainer edits.", + }), + ); + } + if ( + input.title === undefined && + input.body === undefined && + input.maintainerCanModify === undefined + ) { return Effect.fail( new PullRequestOperationError({ operation: "update", @@ -2151,6 +2169,9 @@ export const make = Effect.gen(function* () { repository: project.repository, host: project.host, number: input.number, + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }), }).pipe(Effect.mapError(toPullRequestError("update"))); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf4..48c4a5c240ea 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -190,6 +190,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + countThreads: unused, + listActiveShellIndex: () => Effect.succeed([]), listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index cbe7b35ef409..54742a0af7d6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -726,10 +726,15 @@ export const make = Effect.gen(function* () { return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; - const [projectSnapshot, shellSnapshot] = yield* Effect.all([ - projects.snapshot, - threads.getShellSnapshot(), - ]); + const projectSnapshot = yield* projects.snapshot; + // Subagents never publish awareness, and they are most threads. + const topLevelThreadIds = (yield* threads.listActiveShellIndex()).flatMap((thread) => + thread.lineage.relationshipToParent === "subagent" ? [] : [thread.id], + ); + const shellSnapshot = yield* threads.getShellSnapshot({ + location: "active", + threadIds: topLevelThreadIds, + }); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, startedAt, diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 770359b3e930..70dc2e8d750e 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -8,6 +8,7 @@ import { type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, DesktopTelemetryControlMessage, + type DesktopUpdateChannel, type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; @@ -149,6 +150,12 @@ export interface DesktopTelemetryReceiverHealth { readonly lastError: Option.Option; } +/** Moves the desktop app to `channel` and installs only `targetVersion`. */ +export interface DesktopTrackSwitch { + readonly channel: DesktopUpdateChannel; + readonly targetVersion: string; +} + export class DesktopTelemetryReceiver extends Context.Service< DesktopTelemetryReceiver, { @@ -176,6 +183,7 @@ export class DesktopTelemetryReceiver extends Context.Service< ) => Effect.Effect; readonly requestDesktopUpdate: ( requestId: string, + trackSwitch?: DesktopTrackSwitch, ) => Effect.Effect; readonly commitDesktopUpdate: ( requestId: string, @@ -633,11 +641,12 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( - function (requestId) { + function (requestId, trackSwitch) { return sendControlMessage({ version: 1, type: "requestDesktopUpdate", requestId, + ...trackSwitch, }); }, ), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index c1dcdd22abba..82d165972750 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -402,3 +402,77 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea }).pipe(Effect.provide(layer)); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect("a bound run follows its thread when promotion moves it mid-send", () => + Effect.gen(function* () { + const scratch = ProjectId.make("project:scratch"); + const promoted = ProjectId.make("project:promoted"); + const sends = yield* Ref.make>([]); + // Rebind lands first, then the promotion commits between the shell read and the retry. + const shellProjects = yield* Ref.make([scratch, promoted]); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + sendToThread: (input) => + Ref.updateAndGet(sends, (all) => [ + ...all, + { projectId: input.projectId, commandId: input.commandId }, + ]).pipe( + Effect.flatMap((all) => + all.length < 3 + ? Effect.fail( + new ThreadManagementService.ThreadManagementThreadNotFoundError({ + projectId: input.projectId, + threadId: input.threadId, + }), + ) + : Effect.succeed({} as never), + ), + ), + getThreadShell: () => + Ref.modify(shellProjects, ([next, ...rest]) => [ + { projectId: next ?? promoted } as never, + rest, + ]), + }), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + NodeCrypto.layer, + Scheduler.layer, + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const threadId = ThreadId.make("thread:promoted-during-run"); + const { task } = yield* service.upsert({ + id: ScheduledTaskId.make("task:promoted-during-run"), + title: "Check in", + prompt: "Check progress", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: promoted, + threadId, + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + + const result = yield* service.runNow({ id: task.id }); + + assert.equal(result.threadId, threadId); + const attempts = yield* Ref.get(sends); + assert.deepEqual( + attempts.map((attempt) => attempt.projectId), + [promoted, scratch, promoted], + ); + assert.equal(new Set(attempts.map((attempt) => attempt.commandId)).size, 1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 119cf011c63b..e196528ccb46 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -2,6 +2,7 @@ import { CommandId, MessageId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ProjectId, ScheduledTask, ScheduledTaskError, ScheduledTaskId, @@ -250,6 +251,11 @@ export class ScheduledTaskService extends Context.Service< ) => Effect.Effect; /** Deletes only tasks that still target this thread, including their webhook deliveries. */ readonly deleteForThread: (threadId: ThreadId) => Effect.Effect; + /** Points tasks bound to these threads at the project the threads moved to. */ + readonly moveThreadTasks: (input: { + readonly threadIds: ReadonlyArray; + readonly projectId: ProjectId; + }) => Effect.Effect; /** The dispatched thread, or null if dispatch failed; leaves the task's binding unchanged. */ readonly runNow: ( input: ScheduledTaskRunNowInput, @@ -849,6 +855,42 @@ export const layer = Layer.effect( createdBy: active.createdBy, creationSource: active.creationSource, }; + // A bound thread can move to another project (No project to a project) while + // this fires. Follow it; one move needs at most two retries. Same ids replay safely. + const sendBound = ( + threadId: ThreadId, + projectId: ProjectId, + retries: number, + ): ReturnType => + threadManagement + .sendToThread({ + projectId, + commandId, + threadId, + messageId, + scheduledTaskId: active.id, + text: prompt, + attachments: [], + mode: "queue", + createdBy: active.createdBy, + creationSource: active.creationSource, + }) + .pipe( + Effect.catchTags({ + ThreadManagementThreadNotFoundError: (error) => + retries === 0 + ? Effect.fail(error) + : threadManagement.getThreadShell(threadId).pipe( + Effect.orElseSucceed(() => null), + Effect.flatMap((shell) => + shell === null || shell.projectId === projectId + ? Effect.fail(error) + : sendBound(threadId, shell.projectId, retries - 1), + ), + ), + }), + ); + // Capture defects and interrupts too so dispatch failures still reach markCompleted. const result = active.threadId === null @@ -863,20 +905,9 @@ export const layer = Layer.effect( : yield* Effect.exit( // The stored selection only seeds fresh threads. A bound run // follows the thread's current provider, so it passes none. - threadManagement - .sendToThread({ - projectId: active.projectId, - commandId, - threadId: ThreadId.make(active.threadId), - messageId, - scheduledTaskId: active.id, - text: prompt, - attachments: [], - mode: "queue", - createdBy: active.createdBy, - creationSource: active.creationSource, - }) - .pipe(Effect.as(ThreadId.make(active.threadId))), + sendBound(ThreadId.make(active.threadId), active.projectId, 2).pipe( + Effect.as(ThreadId.make(active.threadId)), + ), ); const runSucceeded = result._tag === "Success"; @@ -1342,6 +1373,22 @@ export const layer = Layer.effect( if (removed.length > 0) yield* notifyChanged; }); + const moveThreadTasks: ScheduledTaskService["Service"]["moveThreadTasks"] = Effect.fn( + "ScheduledTaskService.moveThreadTasks", + )(function* (input) { + if (input.threadIds.length === 0) return; + const moved = yield* sql<{ task_id: ScheduledTaskId }>` + UPDATE scheduled_tasks SET project_id = ${input.projectId} + WHERE project_id != ${input.projectId} AND thread_id IN ${sql.in(input.threadIds)} + RETURNING task_id + `.pipe( + Effect.mapError((cause) => + taskError("Could not move the thread's schedule tasks.", { cause }), + ), + ); + if (moved.length > 0) yield* notifyChanged; + }); + const runNow: ScheduledTaskService["Service"]["runNow"] = Effect.fn( "ScheduledTaskService.runNow", )(function* (input: ScheduledTaskRunNowInput) { @@ -2218,6 +2265,7 @@ export const layer = Layer.effect( setEnabled, delete: deleteTask, deleteForThread, + moveThreadTasks, runNow, rotateWebhookToken, listWebhookDeliveries, diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 195973542551..6d89f45cb792 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -44,7 +44,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError new ServerOwnershipError({ stateDir, cause }))); - yield* Effect.acquireRelease( + const lockPath = NodePath.join(stateDir, "server-owner.sqlite"); + const lock = yield* Effect.acquireRelease( Effect.try({ try: () => { NodeFS.mkdirSync(stateDir, { recursive: true }); - const db = new NodeSqlite.DatabaseSync(NodePath.join(stateDir, "server-owner.sqlite")); + let before = NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + if (before === undefined) { + NodeFS.closeSync(NodeFS.openSync(lockPath, "a", 0o644)); + before = NodeFS.statSync(lockPath, { bigint: true }); + } + const db = new NodeSqlite.DatabaseSync(lockPath); try { db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + const identity = NodeFS.statSync(lockPath, { bigint: true }); + return { + db, + identity, + replacedDuringAcquire: before.dev !== identity.dev || before.ino !== identity.ino, + }; } catch (cause) { db.close(); throw cause; } - return db; }, catch: (cause) => isSqliteBusy(cause) ? new ServerAlreadyRunningError({ stateDir }) : new ServerOwnershipError({ stateDir, cause }), }), - (db) => + ({ db }) => readPersistedServerRuntimeState(statePath).pipe( Effect.flatMap((state) => Option.isSome(state) && state.value.ownerId === ownerId @@ -233,6 +244,39 @@ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(functi return yield* new OlderBootServiceError({ baseDir: olderServiceHome }); } + yield* Effect.gen(function* () { + let lost = lock.replacedDuringAcquire; + let statWarningLogged = false; + while (!lost) { + yield* Effect.sleep("5 seconds"); + // Closing another descriptor for this inode releases SQLite's POSIX locks. Stat by path only. + const current = yield* Effect.sync(() => { + try { + return NodeFS.statSync(lockPath, { bigint: true, throwIfNoEntry: false }); + } catch (cause) { + return cause instanceof Error && "code" in cause ? String(cause.code) : "unknown"; + } + }); + if (typeof current === "string") { + if (!statWarningLogged) { + yield* Effect.logWarning( + `Cannot check server ownership lock at ${lockPath} (${current}).`, + ); + statWarningLogged = true; + } + continue; + } + statWarningLogged = false; + lost = + current === undefined || + current.dev !== lock.identity.dev || + current.ino !== lock.identity.ino; + } + yield* Effect.logError( + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ); + }).pipe(Effect.forkScoped); + return { publish: (state: PersistedServerRuntimeState) => persistServerRuntimeState({ path: statePath, state: { ...state, ownerId } }), diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index d2e98e575218..b53d6f30d3a1 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -142,13 +142,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), + countThreads: () => Effect.succeed(0), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({}), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index add2322b5520..be6944a83c79 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -179,13 +179,8 @@ it.effect.each([ }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - threads: [], - archivedThreads: [], - schemaVersion: 1, - snapshotSequence: 0, - }), + countThreads: () => Effect.succeed(0), + listActiveShellIndex: () => Effect.succeed([]), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch: (input) => { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 18541c0b95fe..945f7b84a29f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -166,11 +166,11 @@ const recordStartupHeartbeat = Effect.gen(function* () { const { threadCount, projectCount } = yield* Effect.all({ projects: projects.snapshot, - threads: threads.getShellSnapshot(), + threadCount: threads.countThreads(), }).pipe( - Effect.map(({ projects: projectSnapshot, threads: shellSnapshot }) => ({ + Effect.map(({ projects: projectSnapshot, threadCount }) => ({ projectCount: projectSnapshot.projects.length, - threadCount: shellSnapshot.threads.length + shellSnapshot.archivedThreads.length, + threadCount, })), Effect.catch((cause) => Effect.logWarning("failed to gather V2 startup counts for telemetry", { @@ -292,8 +292,7 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: path.basename(serverConfig.cwd) || "project", workspaceRoot: serverConfig.cwd, }); - const shell = yield* threads.getShellSnapshot(); - const existingThread = shell.threads.find( + const existingThread = (yield* threads.listActiveShellIndex()).find( (thread) => thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", ); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index abafbc7e24f6..b75ab72594ad 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -1,13 +1,20 @@ +// @effect-diagnostics nodeBuiltinImport:off - Injects a real file replacement during the native SQLite transaction. +import * as NodeFS from "node:fs"; +import * as NodeSqlite from "node:sqlite"; + import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Runtime from "effect/Runtime"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { ServiceLauncherHostProcess } from "./cloud/serviceLauncherClient.ts"; @@ -179,6 +186,7 @@ describe("server ownership", () => { Effect.flip, ); assert.equal(refused._tag, "ServerAlreadyRunningError"); + assert.include(refused.message, "do not delete it while a server is running"); assert.equal(yield* fs.readFileString(statePath), before); }), ); @@ -187,6 +195,89 @@ describe("server ownership", () => { }).pipe(Effect.provide(layerOwnership)), ); + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("lock file checks", () => { + it.effect.each([ + "unchanged", + "unlink", + "replace", + "during acquisition", + "first acquisition", + // Root bypasses directory permissions, so chmod cannot force EACCES. + ...(process.getuid?.() === 0 ? [] : (["stat error"] as const)), + ] as const)("monitors ownership after %s", (operation) => { + const messages: unknown[] = []; + const warnings: unknown[] = []; + const logger = Logger.make(({ message, logLevel }) => + (logLevel === "Warn" ? warnings : messages).push(message), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-owner-lost-" }); + const statePath = path.join(root, "server-runtime.json"); + const lockPath = path.join(root, "server-owner.sqlite"); + const replacement = path.join(root, "replacement.sqlite"); + if (operation === "during acquisition" || operation === "first acquisition") { + if (operation === "during acquisition") yield* fs.writeFileString(lockPath, ""); + yield* fs.writeFileString(replacement, ""); + const exec = NodeSqlite.DatabaseSync.prototype.exec; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(NodeSqlite.DatabaseSync.prototype, "exec").mockImplementationOnce(function ( + this: NodeSqlite.DatabaseSync, + sql: string, + ) { + exec.call(this, sql); + NodeFS.renameSync(replacement, lockPath); + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + } + yield* ServerOwnership.acquireServerOwnership(statePath); + if (operation === "unlink") { + yield* fs.remove(lockPath); + } else if (operation === "replace") { + yield* fs.writeFileString(replacement, ""); + yield* fs.rename(replacement, lockPath); + } + if (operation === "stat error") { + yield* fs.chmod(root, 0); + yield* TestClock.adjust("15 seconds").pipe( + Effect.ensuring(fs.chmod(root, 0o700).pipe(Effect.orDie)), + ); + assert.deepEqual(warnings, [ + [`Cannot check server ownership lock at ${lockPath} (EACCES).`], + ]); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(messages, []); + yield* fs.remove(lockPath); + } + yield* TestClock.adjust("5 seconds"); + const expected = + operation === "unchanged" + ? [] + : [ + [ + `Server ownership lock at ${lockPath} was deleted or replaced. Another server may now share this T3 home. Stop any other server using this home, then restart this server. Do not delete server-owner.sqlite while a server is running.`, + ], + ]; + assert.deepEqual(messages, expected); + if (operation !== "unchanged") { + yield* fs.remove(`${lockPath}-journal`, { force: true }); + yield* Effect.scoped(ServerOwnership.acquireServerOwnership(statePath)); + } + yield* TestClock.adjust("15 seconds"); + assert.deepEqual(messages, expected); + assert.lengthOf(warnings, operation === "stat error" ? 1 : 0); + }).pipe( + Effect.provide( + Layer.merge(layerOwnership, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }); + }); + it.effect("refuses beside a live pre-lock server and ignores a stale record", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index bd985cab203a..f34b653c8cbc 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -212,6 +212,31 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("keeps an override key a newer server saved when a project setting is edited", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const projectId = ProjectId.make("project-a"); + yield* fs.writeFileString( + config.settingsPath, + `{ "projectSettingsOverrides": { "project-a": { "defaultAutoPull": true, "addedByNewerServer": 3 } } }`, + ); + const loaded = yield* service.getSettings; + + yield* service.updateSettings({ + projectSettingsOverrides: { + [projectId]: { ...loaded.projectSettingsOverrides[projectId], defaultAutoPull: false }, + }, + }); + + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.projectSettingsOverrides, { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 3 }, + }); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("moves an idle compaction mode saved in the legacy switch to its own field", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -235,6 +260,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("sends clients a known secret provider's config but never an unknown kind's", () => + Effect.gen(function* () { + const settings = yield* decodeServerSettings({ + secretProviders: { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault", config: { token: "bao-token" } }, + }, + }); + assert.deepStrictEqual( + ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders, + { + personal: { kind: "one_password", label: "Personal", config: { account: "my" } }, + vault: { kind: "openbao", label: "Vault" }, + }, + ); + }), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e..6f6a1db5a292 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -3,10 +3,12 @@ import { ModelSelection, ProjectId, ProjectScript, + keepUnknownOverrideKeys, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, defaultInstanceIdForDriver, + isKnownSecretProviderKind, isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, @@ -128,11 +130,25 @@ const normalizeServerSettings = ( ); export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { - return mapSettingsSecrets(settings, (secret) => + const redacted = mapSettingsSecrets(settings, (secret) => secret.sensitive ? { value: "", redacted: secret.redacted || secret.value.length > 0 } : { value: secret.value, redacted: false }, ); + return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) }; +} + +// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server. +function withoutUnknownSecretProviderConfigs( + settings: ServerSettings, +): ServerSettings["secretProviders"] { + return Object.fromEntries( + Object.entries(settings.secretProviders).map(([id, entry]) => { + if (isKnownSecretProviderKind(entry.kind)) return [id, entry]; + const { config: _config, ...envelope } = entry; + return [id, envelope]; + }), + ) as ServerSettings["secretProviders"]; } export function applyProviderInstanceMutation( @@ -277,10 +293,12 @@ const ServerSettingsJson = fromLenientJson( ), projectSettingsOverrides: Schema.Record( ProjectId, - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + responseStreamingMode: Schema.optionalKey(PersistedResponseStreamingMode), + }), + ), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), }), ); @@ -435,6 +453,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "reviewGuideModelSelection", "textGenerationModelSelection", + "fallbackModelSelection", "pullRequestMergeMethod", ]); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index b80ccabc0360..dff6b96f52a1 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -4,6 +4,7 @@ import { CodexSettings, EasDeviceHostConfig, type ServerSettings, + SimlockDeviceHostConfig, UsageLimitSourceConfig, secretSettingsFields, } from "@t3tools/contracts"; @@ -33,6 +34,7 @@ const providerSecretFields = new Map([ ]); const usageLimitSecretFields = secretSettingsFields(UsageLimitSourceConfig); const easDeviceHostSecretFields = secretSettingsFields(EasDeviceHostConfig); +const simlockDeviceHostSecretFields = secretSettingsFields(SimlockDeviceHostConfig); const encode = (value: string) => Buffer.from(value, "utf8").toString("base64url"); export interface SettingsSecret { @@ -155,5 +157,24 @@ export function mapSettingsSecrets( ) as EasDeviceHostConfig, ); - return { ...settings, providerInstances, sourceControlHosts, usageLimitSources, easDeviceHosts }; + // Its own prefix: an EAS and a Simlock host may share an id. + const simlockDeviceHosts = settings.simlockDeviceHosts.map( + (host) => + mapFields( + host, + simlockDeviceHostSecretFields, + (field, serverHost) => + `simlock-device-host-${encode(host.id)}-${encode(field)}` + + (serverHost === null ? "" : `-${encode(serverHost)}`), + ) as SimlockDeviceHostConfig, + ); + + return { + ...settings, + providerInstances, + sourceControlHosts, + usageLimitSources, + easDeviceHosts, + simlockDeviceHosts, + }; } diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 249bc07b3ad3..05b6a1a8d0e4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -118,7 +118,12 @@ function makeRegistry(input: { kind: provider.kind, label: provider.kind, installHint: "unused", - probeAuth: Effect.die("unused"), + probeAuth: Effect.succeed({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), }, })), ).pipe( @@ -440,3 +445,28 @@ it.effect("asks again once Settings change", () => assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); }), ); + +it.effect("answers discovery without waiting on a hosting API that stalls", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + discovery: [ + claimingCli("gitlab"), + { + type: "api", + kind: kind("bitbucket"), + label: "Bitbucket", + installHint: "unused", + probeAuth: Effect.never, + }, + ], + }); + const discovering = yield* registry.discover.pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + const items = yield* Fiber.join(discovering); + const bitbucket = items.find((item) => item.kind === "bitbucket"); + assert.strictEqual(bitbucket?.status, "available"); + assert.strictEqual(bitbucket?.auth.status, "unknown"); + assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 4a9105e9f5d9..b0770b06d483 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -383,6 +383,62 @@ it.effect.each([ }), ); +it.effect.each([ + ["a plain token", "s3cret"], + ["a token with an apostrophe", "s3'cret"], + ["a token with a double quote", 's3"cret'], + ["a host-only token with an at sign", "abc@secret"], + ["an unquoted token with an apostrophe", "s3'cret", true], + ["an unquoted token with a double quote", 's3"cret', true], + ["an unquoted URL with an apostrophe in its path", "path-token", true], +] as const)("keeps %s in the query out of a failed clone's error text", ([, token, unquoted]) => + Effect.gen(function* () { + const remote = + token === "abc@secret" + ? "https://github.com" + : token === "path-token" + ? "https://github.com/octocat/t3code's.git" + : "https://github.com/octocat/t3code.git"; + const error = yield* Effect.flip( + cloneInTemporaryDirectory( + { + execute: (input) => + Effect.gen(function* () { + const cloneUrl = input.args.at(-2)!; + // Git treats @ in a pathless URL's query as userinfo, losing the query delimiter. + const diagnosticUrl = + cloneUrl === "https://github.com?access_token=abc@secret" + ? "https://secret" + : cloneUrl; + yield* ( + input.progress?.onStderrLine?.( + unquoted + ? `fatal: ${diagnosticUrl}/info/refs not valid: is this a git repository?` + : `fatal: unable to access '${diagnosticUrl}/': Could not resolve host: github.com`, + ) ?? Effect.void + ); + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command exited with a non-zero status.", + exitCode: 128, + }); + }), + }, + { remoteUrl: `${remote}?access_token=${token}` }, + ), + ); + + assert.strictEqual( + error.detail, + unquoted + ? `fatal: ${remote} not valid: is this a git repository?` + : `fatal: unable to access '${remote}${token === "abc@secret" ? "/" : ""}': Could not resolve host: github.com`, + ); + }), +); + it.effect("strips embedded credentials from the remote URL it reports", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b6e767903704..44d31a43b697 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -110,7 +110,7 @@ function toRepositoryInfo( }; } -// Redact credentials from client clone snapshots; git still receives the original URL. +// Redact credentials from client clone snapshots; git still receives the credentials. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -128,10 +128,16 @@ function redactRemoteUrl(remoteUrl: string): string { } // Userinfo can contain @; remove through the last one before the host boundary. -const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; +const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/gi; +// Git leaves quotes inside query tokens unescaped; consume through the closing quote. +const QUOTED_URL_QUERY = /(['"])([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?.*\1(?=[\s:]|$)/gi; +const URL_QUERY = /\b([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?[^\s]*/gi; function redactUrlCredentials(text: string): string { - return text.replace(URL_WITH_USERINFO, "$1"); + return text + .replace(QUOTED_URL_QUERY, "$1$2$1") + .replace(URL_QUERY, "$1") + .replace(URL_WITH_USERINFO, "$1"); } // The remote already exists after push failure; advise fixing access and pushing. @@ -340,11 +346,13 @@ export const make = Effect.gen(function* () { if (stderrTail.length > 4) stderrTail.shift(); }); }; + // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. + const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); yield* git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), - args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], + args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 9d674969e2fb..6e9760c302c2 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -17,6 +17,8 @@ import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen, type StorageCleanupSettings, + type TerminalMetadataStreamEvent, + type TerminalSummary, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "./config.ts"; @@ -36,8 +38,11 @@ import { CodexProviderCapabilitiesV2 } from "./orchestration-v2/Adapters/CodexAd import { ServerActivation } from "./serverActivation.ts"; import { describe, expect, it } from "@effect/vitest"; import { + EventId, ProjectId, + ProviderDriverKind, ProviderInstanceId, + ProviderSessionId, RunId, RuntimeRequestId, ThreadId, @@ -277,8 +282,11 @@ const cleanupFixture = ( let lateClaim: string | null = null; let worktreePathReads = 0; let sessionLive = true; + let onLiveCheck: Effect.Effect = Effect.void; + let outboxStatus: string | null = null; let fullStatusCalls = 0; let snapshotReads = 0; + let terminalListener: ((event: TerminalMetadataStreamEvent) => Effect.Effect) | undefined; let events: Stream.Stream = Stream.empty; const activated = yield* Deferred.make(); const processRunner = yield* ProcessRunner.make(); @@ -356,7 +364,8 @@ const cleanupFixture = ( Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null), } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, { - isLive: () => Effect.sync(() => sessionLive), + isLive: () => + Effect.sync(() => sessionLive).pipe(Effect.tap(() => Effect.suspend(() => onLiveCheck))), } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]), Effect.provideService(Orchestrator.OrchestratorV2, { streamDomainEvents: Stream.suspend(() => events), @@ -380,31 +389,41 @@ const cleanupFixture = ( }), }, ] - : strings.join("").includes("deleted_at IS NOT NULL") - ? deletedThreads.map((thread) => ({ - payload_json: JSON.stringify(thread), - workspaceRoot: repo, - })) - : strings.join("").includes('AS "worktreePath"') - ? [ - ...threads.flatMap((thread) => thread.worktreePath ?? []), - ...storedWorktreePaths, - // Recorded after the sweep's first look at the threads. - ...(++worktreePathReads > 1 && lateClaim !== null ? [lateClaim] : []), - ].map((worktreePath) => ({ worktreePath })) - : [], + : outboxStatus !== null && strings.join("").includes("orchestration_v2_effect_outbox") + ? [{ status: outboxStatus }] + : strings.join("").includes("deleted_at IS NOT NULL") + ? deletedThreads.map((thread) => ({ + payload_json: JSON.stringify(thread), + workspaceRoot: repo, + })) + : strings.join("").includes('AS "worktreePath"') + ? [ + ...threads.flatMap((thread) => thread.worktreePath ?? []), + ...storedWorktreePaths, + // Recorded after the sweep's first look at the threads. + ...(++worktreePathReads > 1 && lateClaim !== null ? [lateClaim] : []), + ].map((worktreePath) => ({ worktreePath })) + : [], )) as unknown as SqlClient.SqlClient), Effect.provideService(GitManager.GitManager, { invalidateStatus: () => Effect.void, } as unknown as GitManager.GitManager["Service"]), Effect.provideService(TerminalManager.TerminalManager, { - subscribeMetadata: () => Effect.succeed(() => {}), + subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => + Effect.sync(() => { + terminalListener = listener; + return () => {}; + }), } as unknown as TerminalManager.TerminalManager["Service"]), Effect.provideService(ServerActivation, Deferred.await(activated)), ); return { service: Context.get(context, StorageCleanup.StorageCleanup), repo, + setTerminals: (terminals: TerminalSummary[]) => + terminalListener?.({ type: "snapshot", terminals }) ?? Effect.void, editBeforeRemoval: () => { editBeforeRemoval = true; }, @@ -444,6 +463,12 @@ const cleanupFixture = ( setThreads: (next: typeof threads) => { threads = next; }, + setOnLiveCheck: (hook: Effect.Effect) => { + onLiveCheck = hook; + }, + setOutboxStatus: (status: string | null) => { + outboxStatus = status; + }, setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => { deletedThreads = next; }, @@ -952,6 +977,322 @@ describe("deleted thread worktrees", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + // Releases one stopped-session event after the startup sweep and returns the snapshot reads it caused. + const stopSessionAfterStartupSweep = ( + fixture: Effect.Success>, + threadId: ThreadId, + beforeStop: Effect.Effect = Effect.void, + cwd = fixture.worktree, + ) => + Effect.gen(function* () { + const startupSwept = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + yield* beforeStop; + const readsBefore = fixture.snapshotReads(); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + return fixture.snapshotReads() - readsBefore; + }); + + it.effect("does not sweep when a live thread's session stops and no deleted worktree waits", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + const reads = yield* stopSessionAfterStartupSweep(fixture, ThreadId.make("thread-1")); + expect(reads, "a live thread's stopped session re-read every thread").toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once its session stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.sync(() => fixture.setSessionLive(false)), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("removes a deleted thread's worktree once a live thread's session in it stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + const child = subagent(owner); + fixture.setDeletedThreads([owner]); + fixture.setThreads([child]); + fixture.setOpenSessionCwd(fixture.worktree); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + child.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Provider session is still open", + }); + fixture.setSessionLive(false); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree once its terminal closes and its session stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.setTerminals([ + { + threadId: owner.id, + terminalId: "terminal-1", + cwd: fixture.worktree, + worktreePath: fixture.worktree, + status: "running", + pid: 1, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal", + updatedAt: DateTime.formatIso(at(0)), + }, + ]); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Open terminal", + }); + yield* fixture.setTerminals([]); + }), + fixture.repo, + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "retries a deleted thread's worktree kept for another reason when a session in it stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + owner.id, + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Has uncommitted changes (1 file)", + }); + yield* fixture.fs + .writeFileString(`${fixture.worktree}/tracked.txt`, "original\n") + .pipe(Effect.orDie); + }), + ); + expect(reads).toBeGreaterThan(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("does not sweep for a kept deleted worktree when a session elsewhere stops", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + fixture.setDeletedThreads([parent(fixture.worktree)]); + fixture.setThreads([]); + yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "agent edit\n"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.void, + fixture.repo, + ); + expect(reads).toBe(0); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "does not sweep for a deleted thread whose cleanup effect failed when a session elsewhere stops", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setDeletedThreads([owner]); + fixture.setThreads([]); + fixture.setOutboxStatus("failed"); + const reads = yield* stopSessionAfterStartupSweep( + fixture, + ThreadId.make("thread-elsewhere"), + Effect.gen(function* () { + expect((yield* fixture.service.latestReport)?.entries[0]).toMatchObject({ + threadId: owner.id, + outcome: "kept", + reason: "Thread deletion is still pending", + }); + }), + fixture.repo, + ); + expect(reads).toBe(0); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect( + "removes a deleted thread's worktree when its session stops during the deletion's sweep", + () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeAfterDays: null, + worktreeOnDelete: true, + }); + const owner = parent(fixture.worktree); + fixture.setThreads([]); + const startupSwept = yield* Deferred.make(); + const sessionChecked = yield* Deferred.make(); + const delivered = yield* Deferred.make(); + const deleted = { + id: EventId.make("event:thread-deleted"), + type: "thread.deleted", + threadId: owner.id, + occurredAt: at(0), + payload: owner, + } as unknown as OrchestrationV2DomainEvent; + const stopped: OrchestrationV2DomainEvent = { + id: EventId.make("event:session-stopped"), + type: "provider-session.updated", + threadId: owner.id, + occurredAt: at(0), + payload: { + id: ProviderSessionId.make("session-1"), + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + cwd: fixture.worktree, + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: at(-DAY_MS), + updatedAt: at(0), + lastError: null, + }, + }; + yield* fixture.activate( + Stream.fromEffect(Deferred.await(startupSwept)).pipe( + Stream.flatMap(() => Stream.make(deleted)), + Stream.concat(Stream.fromEffect(Deferred.await(sessionChecked)).pipe(Stream.drain)), + Stream.concat(Stream.make(stopped)), + Stream.concat( + Stream.fromEffect(Deferred.succeed(delivered, undefined)).pipe(Stream.drain), + ), + ), + ); + yield* fixture.service.reports.pipe( + Stream.filter((report) => report !== null), + Stream.take(1), + Stream.runDrain, + ); + yield* fixture.service.drain; + fixture.setDeletedThreads([owner]); + fixture.setOpenSessionCwd(fixture.worktree); + // The deletion's sweep sees the session live; it stops before that sweep publishes its result. + fixture.setOnLiveCheck( + Effect.gen(function* () { + fixture.setOnLiveCheck(Effect.void); + yield* Deferred.succeed(sessionChecked, undefined); + yield* Deferred.await(delivered); + fixture.setSessionLive(false); + }), + ); + yield* Deferred.succeed(startupSwept, undefined); + yield* Deferred.await(delivered); + yield* fixture.service.drain; + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(false); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.effect("waits out the settle grace of a subagent that just settled", () => Effect.gen(function* () { yield* TestClock.setTime(NOW_MS); @@ -1841,6 +2182,42 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + { linked: "home", outcome: "removed", reason: "Removed 1 rotated logs" }, + { linked: "logs folder", outcome: "kept", reason: "No expired files" }, + ] as const)( + "rotated logs behind a symlinked $linked are $outcome", + ({ linked, outcome, reason }) => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const disk = yield* fs.realPath(yield* fs.makeTempDirectoryScoped()); + yield* fs.makeDirectory(`${disk}/real/logs`, { recursive: true }); + if (linked === "home") { + yield* fs.symlink(`${disk}/real`, `${disk}/home`); + } else { + yield* fs.makeDirectory(`${disk}/home`); + yield* fs.symlink(`${disk}/real/logs`, `${disk}/home/logs`); + } + yield* fs.writeFileString(`${disk}/real/logs/server.log.1`, "log"); + yield* fs.utimes(`${disk}/real/logs/server.log.1`, 1, 1); + const { service, enableFiles, disable } = yield* cleanupFixture().pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + logsDir: `${disk}/home/logs`, + }), + ); + disable(); + enableFiles(); + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind === "logs")).toMatchObject([ + { outcome, reason }, + ]); + expect(yield* fs.exists(`${disk}/real/logs/server.log.1`)).toBe(outcome === "kept"); + }), + ), + ); it.live("reports git failures without aborting the remaining categories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index e2387ecc1886..818dc1ceecbb 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -873,7 +873,9 @@ const make = Effect.gen(function* () { !anyWorktreePolicy(serverSettings, worktreeCleanupEnabled) && serverSettings.storageCleanup.orphanWorktreeAfterDays === null ) - return; + return { waiting: false, kept: [] }; + let waiting = false; + const kept: string[] = []; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -947,7 +949,15 @@ const make = Effect.gen(function* () { bytes: null, files: null, }; - const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); + const keep = (reason: string) => { + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); + entries.push({ ...entry, reason: sentence(reason) }); + }; + // Reasons a provider session stop or finished deletion work can clear. + const waitForStop = (reason: string) => { + if (deleted && settings.worktreeOnDelete) waiting = true; + return reason; + }; yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); @@ -958,10 +968,10 @@ const make = Effect.gen(function* () { thread.branch === null || !group.every((member) => storageCleanupThreadIdle(member, now)) ) - return keep("thread is running or has pending work"); + return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep("open terminal"); + if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); @@ -985,7 +995,7 @@ const make = Effect.gen(function* () { SETTLE_SCRIPT_GRACE_MS, ) ) - return keep("settled moments ago"); + return keep(waitForStop("settled moments ago")); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); const old = !deleted && @@ -1092,7 +1102,7 @@ const make = Effect.gen(function* () { const latest = (yield* projections.getThreadWorktreePaths({ includeArchived: true, })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return "open terminal"; + if (hasTerminal(worktreePath)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -1100,10 +1110,13 @@ const make = Effect.gen(function* () { ) return "thread or cleanup settings changed since check"; // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. - const pendingCleanup = yield* sql` - SELECT 1 FROM orchestration_v2_effect_outbox - WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 + const pendingCleanup = yield* sql<{ status: string }>` + SELECT status FROM orchestration_v2_effect_outbox + WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') `.pipe(NodeSqliteClient.readOnly); + // Failed effects never retry, so only running work can finish and free the path. + if (pendingCleanup.some((row) => row.status !== "failed")) + return waitForStop("thread deletion is still pending"); if (pendingCleanup.length > 0) return "thread deletion is still pending"; } const latestShells = yield* Effect.forEach(latest, (entry) => @@ -1128,7 +1141,7 @@ const make = Effect.gen(function* () { ); }) ) - return "thread activity or shared worktree changed since check"; + return waitForStop("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -1144,7 +1157,7 @@ const make = Effect.gen(function* () { (cwd === worktreePath || inside(worktreePath, cwd)) && (yield* providerSessions.isLive(session.id)) ) - return "provider session is still open"; + return waitForStop("provider session is still open"); } return null; }); @@ -1192,6 +1205,7 @@ const make = Effect.gen(function* () { outcome: "failed", reason: cleanupFailureReason(error), }); + if (deleted && settings.worktreeOnDelete) kept.push(worktreePath); yield* Effect.logWarning("storage cleanup failed for worktree", { threadId: thread.id, error, @@ -1206,6 +1220,7 @@ const make = Effect.gen(function* () { serverSettings, entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1216,8 +1231,10 @@ const make = Effect.gen(function* () { removed: { files: number; bytes: number }, ) { if (days === null || !(yield* fs.exists(root))) return removed; + // A symlinked parent (a linked T3 home) is fine; a symlinked folder is not. const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return removed; + const realParent = yield* fs.realPath(path.dirname(root)); + if (realRoot !== path.join(realParent, path.basename(root))) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -1245,14 +1262,21 @@ const make = Effect.gen(function* () { }); const reportRef = yield* SubscriptionRef.make(null); + // Only deleted threads' worktrees wait on session stops: any stop while one waits, or a stop inside one kept for another reason. + // A deletion stays pending until a sweep that started after it finishes. + let deletedWorktreeWaits = true; + let keptDeletedWorktrees: ReadonlyArray = []; + let deletions = 0; + let sweptDeletions = 0; const sweep = Effect.fn("StorageCleanup.sweep")(function* ( trigger: StorageCleanupReport["trigger"], ) { + const deletionsAtStart = deletions; const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; const entries: StorageCleanupReportEntry[] = []; - yield* cleanWorktrees(serverSettings, now, entries).pipe( + const deletedWorktrees = yield* cleanWorktrees(serverSettings, now, entries).pipe( Effect.catch((error) => Effect.gen(function* () { entries.push({ @@ -1267,9 +1291,13 @@ const make = Effect.gen(function* () { files: null, }); yield* Effect.logWarning("worktree cleanup failed", { error }); + return { waiting: true, kept: [] }; }), ), ); + deletedWorktreeWaits = deletedWorktrees.waiting; + keptDeletedWorktrees = deletedWorktrees.kept; + sweptDeletions = deletionsAtStart; for (const category of [ { kind: "browser-artifacts" as const, @@ -1410,14 +1438,26 @@ const make = Effect.gen(function* () { }), ); yield* forkParked( - Stream.runForEach(events, (event) => - (event.type === "thread.deleted" || - (event.type === "provider-session.updated" && - (event.payload.status === "stopped" || event.payload.status === "error"))) && - anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) + Stream.runForEach(events, (event) => { + if (event.type === "thread.deleted") deletions++; + else if ( + event.type !== "provider-session.updated" || + (event.payload.status !== "stopped" && event.payload.status !== "error") + ) + return Effect.void; + else if (!deletedWorktreeWaits && deletions === sweptDeletions) { + const cwd = pathKey(event.payload.cwd); + if ( + !keptDeletedWorktrees.some( + (worktreePath) => pathKey(worktreePath) === cwd || inside(worktreePath, cwd), + ) + ) + return Effect.void; + } + return anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) ? requestSweep - : Effect.void, - ).pipe( + : Effect.void; + }).pipe( Effect.catchCause((cause) => Effect.logWarning("Storage cleanup event stream failed", { cause }), ), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c6b3b5ee7983..be045886b3a3 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -881,6 +881,35 @@ it.layer( }), ); + it.effect.each([ + { name: "OSC", sequence: "\u001b]0;unfinished" }, + { name: "DCS", sequence: "\u001bPunfinished" }, + { name: "PM", sequence: "\u001b^unfinished" }, + { name: "APC", sequence: "\u001b_unfinished" }, + { name: "CSI", sequence: "\u001b[5" }, + { name: "C1 OSC", sequence: "\u009d0;unfinished" }, + { name: "C1 DCS", sequence: "\u0090unfinished" }, + { name: "C1 PM", sequence: "\u009eunfinished" }, + { name: "C1 APC", sequence: "\u009funfinished" }, + { name: "C1 CSI", sequence: "\u009b5" }, + ])("keeps output after a cancelled $name sequence in history", ({ sequence }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + for (const cancel of ["\u0018", "\u001a"]) { + process.emitData(sequence); + process.emitData(cancel); + process.emitData("next line\n"); + process.emitData(`${sequence}${cancel}next line\n`); + } + yield* manager.close({ threadId: "thread-1" }); + + const reopened = yield* manager.open(openInput()); + expect(reopened.history.match(/next line/g)).toHaveLength(4); + }), + ); + it.effect("handles CSI and DCS query sequences split across output chunks", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 4c5229a7bae4..a08b3e4afe78 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1067,6 +1067,10 @@ function isCsiFinalByte(codePoint: number): boolean { return codePoint >= 0x40 && codePoint <= 0x7e; } +function isControlSequenceCancel(codePoint: number): boolean { + return codePoint === 0x18 || codePoint === 0x1a; +} + function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "n") { return true; @@ -1115,7 +1119,7 @@ function stripStringTerminator(value: string): string { function findStringTerminatorIndex(input: string, start: number): number | null { for (let index = start; index < input.length; index += 1) { const codePoint = input.charCodeAt(index); - if (codePoint === 0x07 || codePoint === 0x9c) { + if (codePoint === 0x07 || codePoint === 0x9c || isControlSequenceCancel(codePoint)) { return index + 1; } if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) { @@ -1177,10 +1181,11 @@ function sanitizeTerminalHistoryChunk( if (nextCodePoint === 0x5b) { let cursor = index + 2; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 2, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; @@ -1231,10 +1236,11 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x9b) { let cursor = index + 1; while (cursor < input.length) { - if (isCsiFinalByte(input.charCodeAt(cursor))) { + const cancelled = isControlSequenceCancel(input.charCodeAt(cursor)); + if (cancelled || isCsiFinalByte(input.charCodeAt(cursor))) { const sequence = input.slice(index, cursor + 1); const body = input.slice(index + 1, cursor); - if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) { append(sequence); } index = cursor + 1; diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 395a24eeedc8..3fcafcc15178 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -170,6 +170,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* Effect.sync(() => { state.selectedModels.push(model); }), + setConfigOption: () => Effect.succeed({ configOptions: [] }), prompt: (request) => Effect.gen(function* () { state.prompts.push(request); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 670c2eb28d51..921a2f118e34 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -31,6 +31,7 @@ type AntigravityTextRuntime = Pick< | "getConfigOptions" | "getEvents" | "setModel" + | "setConfigOption" | "prompt" | "cancel" | "handleSessionUpdate" diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index a83e09a4110d..04ae83952b3c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,3 +1,13 @@ +import { + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, + ProviderDriverKind, + type ModelSelection, + type ServerProvider, +} from "@t3tools/contracts"; +import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -433,3 +443,96 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); }); + +it.effect.each([false, true])("text generation preflight: exhausted=%s", (exhausted) => + Effect.gen(function* () { + const primary = ProviderInstanceId.make("codex"); + const fallback = ProviderInstanceId.make("claude_work"); + const selection = { + instanceId: primary, + model: "chat-model", + options: [{ id: "effort", value: "high" }], + }; + const calls: ModelSelection[] = []; + const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { + const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); + const snapshot: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + version: null, + checkedAt: "1970-01-01T00:00:00Z", + models: [], + slashCommands: [], + skills: [], + ...(exhausted && instanceId === primary + ? { + usageLimits: { + checkedAt: "1970-01-01T00:00:00Z", + windows: [{ id: "session", kind: "session", label: "Session", usedPercent: 100 }], + }, + } + : {}), + }; + return { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + snapshot: { getSnapshot: Effect.succeed(snapshot) } as ProviderInstance["snapshot"], + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: TextGeneration.TextGeneration.of({ + generateCommitMessage: () => Effect.die("unused"), + generatePrContent: () => Effect.die("unused"), + generateBranchName: () => Effect.die("unused"), + generateReviewGuide: () => Effect.die("unused"), + generateThreadTitle: (input) => + Effect.sync(() => { + calls.push(input.modelSelection); + return { title: "Generated title" }; + }), + }), + }; + }); + const service = yield* TextGeneration.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + listInstances: Effect.succeed(instances), + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + }), + ServerSettings.layerTest({ + providerInstances: { + [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + }, + }), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => Effect.die("unused"), + }), + ), + ), + ); + expect( + yield* service.generateThreadTitle({ + cwd: "/tmp", + message: "hello", + linkedContext: "", + modelSelection: selection, + }), + ).toEqual({ title: "Generated title" }); + expect(calls).toEqual([ + exhausted + ? { + instanceId: fallback, + model: + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], + } + : selection, + ]); + }), +); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 96f266c6f895..fc8d0fb68da3 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,7 +1,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import type { ProviderInstanceId } from "@t3tools/contracts"; +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { + isProviderAvailable, + isProviderTextGenerationCapable, + resolveProjectProviderInstanceEnabled, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; +import { + defaultTextGenerationModel, + fallbackTextGenerationModelSelection, +} from "@t3tools/shared/serverSettings"; +import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -52,36 +64,94 @@ const resolveInstance = ( ), ); +// Review guides investigate the checkout read-only; the other drivers reject that request. +const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]); + /** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const generate = Effect.fn("TextGeneration.generate")(function* ( + operation: TextGenerationOp, + selection: ModelSelection, + invoke: ( + service: ProviderTextGeneration, + modelSelection: ModelSelection, + ) => Effect.Effect, + ) { + const snapshots = yield* registry.listInstances.pipe( + Effect.flatMap((instances) => + Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot), + ), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const limited = exhaustedUsageWindow( + snapshots.find((provider) => provider.instanceId === selection.instanceId), + now, + selection.model, + ); + if (limited) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Could not read fallback model settings.", + cause, + }), + ), + ); + selection = + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) + ); + }, + { includeConfiguredInstances: true }, + ) ?? selection; + } + return yield* resolveInstance(registry, operation, selection.instanceId).pipe( + Effect.flatMap((service) => invoke(service, selection)), + ); + }); return TextGeneration.of({ generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { - return resolveInstance( - registry, - "generateCommitMessage", - input.modelSelection.instanceId, - ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + return generate("generateCommitMessage", input.modelSelection, (service, modelSelection) => + service.generateCommitMessage({ ...input, modelSelection }), + ); }), generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { - return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + return generate("generatePrContent", input.modelSelection, (service, modelSelection) => + service.generatePrContent({ ...input, modelSelection }), ); }), generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { - return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + return generate("generateBranchName", input.modelSelection, (service, modelSelection) => + service.generateBranchName({ ...input, modelSelection }), ); }), generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { - return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), + return generate("generateReviewGuide", input.modelSelection, (service, modelSelection) => + service.generateReviewGuide({ ...input, modelSelection }), ); }), generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { - return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => + return generate( + "generateThreadTitle", + input.modelSelection, + (textGeneration, modelSelection) => Effect.gen(function* () { const linkedContext = input.linkedContext ?? @@ -91,9 +161,12 @@ export const make = Effect.gen(function* () { sourceControl, ), )); - return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); + return yield* textGeneration.generateThreadTitle({ + ...input, + linkedContext, + modelSelection, + }); }), - ), ); }), }); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e036dd148038..54ac6966e335 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -142,6 +142,7 @@ const layerService = (input: { ) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly platform?: NodeJS.Platform; + readonly hostname?: string; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -157,6 +158,7 @@ const layerService = (input: { ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), + Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -465,6 +467,93 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.live("keeps the first host identity across recreation and explicit override removal", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + for (const [hostname, override, expected] of [ + ["container-a", undefined, "container-a"], + ["container-b", undefined, "container-a"], + ["container-c", "physical-host", "physical-host"], + ["container-d", undefined, "container-a"], + ] as const) { + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-host-id", + home, + settings, + hostname, + environment: override === undefined ? {} : { T3CODE_HOST_ID: override }, + }), + ), + ); + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, expected); + } + }).pipe(Effect.scoped), + ); + + it.live("concurrent initialization keeps one host identity across service recreation", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const prefix = "usage-host-id-race"; + const hostIdPath = NodePath.join(home, prefix, "usage-host-id"); + const bothRead = yield* Deferred.make(); + let reads = 0; + const summaries = yield* Effect.forEach( + ["container-a", "container-b"], + (hostname) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let firstRead = true; + const service = yield* UsageService.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + readFileString: (path, encoding) => + Effect.gen(function* () { + if (path !== hostIdPath || !firstRead) { + return yield* fileSystem.readFileString(path, encoding); + } + firstRead = false; + // Both instances observe the missing file before either can create it. + const result = yield* Effect.exit(fileSystem.readFileString(path, encoding)); + reads += 1; + if (reads === 2) yield* Deferred.succeed(bothRead, undefined); + yield* Deferred.await(bothRead); + return yield* result; + }), + }), + ); + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname })), + ), + { concurrency: "unbounded" }, + ); + const persisted = yield* Effect.promise(() => NodeFSP.readFile(hostIdPath, "utf8")); + const hostId = persisted.trim(); + assert.include(["container-a", "container-b"], hostId); + const restarted = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix, home, settings, hostname: "container-c" })), + ); + for (const summary of [...summaries, restarted]) { + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.fingerprint.hostId, hostId); + } + }).pipe(Effect.scoped), + ); + it.live( "repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index ec782b95a78a..a62792783d43 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -40,7 +40,7 @@ import type { UsageRecord, } from "@t3tools/provider-core/server/usage"; import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; -import { BoxUsageStore, makeBoxUsageReads, usageHostId } from "./boxUsage.ts"; +import { BoxUsageStore, makeBoxUsageReads } from "./boxUsage.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, @@ -156,6 +156,7 @@ export const make = Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcess.Environment; + const hostname = yield* HostProcess.Hostname; const readerContext = yield* Effect.context(); const fileCache: ScanCache = new Map(); @@ -178,6 +179,40 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); + const hostIdPath = path.join(config.baseDir, "usage-host-id"); + // Cloud boxes are cloned from one template and share a hostname and seeded home, so the + // provisioner's lease id names each one, ahead of any operator override. + const explicitHostId = + hostEnvironment["T3CODE_USAGE_HOST_ID"]?.trim() || hostEnvironment["T3CODE_HOST_ID"]?.trim(); + const hostId = + explicitHostId || + (yield* fileSystem.readFileString(hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => + Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.makeDirectory(config.baseDir, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: config.baseDir, + prefix: "usage-host-id.", + }); + const tempFile = path.join(tempDirectory, "identity"); + yield* fileSystem.writeFileString(tempFile, `${hostname}\n`); + // Publish complete contents without replacing another initializer's identity. + yield* fileSystem.link(tempFile, hostIdPath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + return yield* fileSystem.readFileString(hostIdPath); + }), + ), + ), + Effect.map((value) => value.trim() || hostname), + Effect.orElseSucceed(() => hostname), + )); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; let ratesLastFailedAtMs: number | null = null; @@ -666,7 +701,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureScanCacheLoaded; - const hostId = usageHostId(hostEnvironment); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -905,7 +939,11 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { ...(yield* makeBoxUsageReads(readSummary)), refreshRates, awaitPersisted } as const; + return { + ...(yield* makeBoxUsageReads(readSummary, hostId)), + refreshRates, + awaitPersisted, + } as const; }); export const layer = Layer.effect(UsageService, make).pipe(Layer.provide(BoxUsageStore.layer)); diff --git a/apps/server/src/usage/boxUsage.ts b/apps/server/src/usage/boxUsage.ts index 3751359ed325..a4450c3fc770 100644 --- a/apps/server/src/usage/boxUsage.ts +++ b/apps/server/src/usage/boxUsage.ts @@ -6,7 +6,6 @@ * * @module boxUsage */ -import * as NodeOS from "node:os"; import { ForwardCompatibleArray, @@ -21,7 +20,6 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import * as HostProcess from "@t3tools/shared/HostProcess"; import { usageSourcePath } from "@t3tools/shared/usageMerge"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -44,13 +42,6 @@ export const BOX_USAGE_RETENTION_DAYS = 90; const isoAt = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); -/** - * The host id usage sources carry. Cloud boxes are cloned from one template - * and share a hostname, so the provisioner names each one. - */ -export const usageHostId = (environment: { readonly T3CODE_USAGE_HOST_ID?: string | undefined }) => - environment.T3CODE_USAGE_HOST_ID?.trim() || NodeOS.hostname(); - /** The order `UsageAggregator` sorts a summary's buckets in. */ const compareUsageBuckets = (a: UsageBucket, b: UsageBucket) => a.day.localeCompare(b.day) || @@ -498,12 +489,12 @@ export const makeBoxUsageReads = Effect.fn("makeBoxUsageReads")(function* ( input: UsageSummaryInput, maxHourlyWindowMs?: number, ) => Effect.Effect, + hostId: string, ) { // Absent where the service is built without storage, as its unit suites do. const boxUsage = yield* Effect.serviceOption(BoxUsageStore); const config = yield* ServerConfig.ServerConfig; const path = yield* Path.Path; - const hostId = usageHostId(yield* HostProcess.Environment); const readSummary = Effect.fn("UsageService.readSummaryWithBoxUsage")(function* ( input: UsageSummaryInput, diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 0e3d2b5063ed..c7a06c3bdc31 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -682,9 +682,13 @@ it.effect.each( }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); -it.effect.each(["normal", "flags", "sparse"] as const)( - "checkpoint index inspection handles entries beyond the output cap (index=%s)", - (indexMode) => +it.effect.each( + (["normal", "flags", "sparse"] as const).flatMap((indexMode) => + [1, 3, 4096].map((chunkSize) => ({ indexMode, chunkSize })), + ), +)( + "checkpoint index inspection handles entries beyond the output cap (index=$indexMode, chunk=$chunkSize)", + ({ indexMode, chunkSize }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -720,8 +724,8 @@ it.effect.each(["normal", "flags", "sparse"] as const)( ...input, maxOutputBytes: 8, onStdoutChunk: (chunk) => { - for (let i = 0; i < chunk.length; i++) - input.onStdoutChunk?.(chunk.subarray(i, i + 1)); + for (let i = 0; i < chunk.length; i += chunkSize) + input.onStdoutChunk?.(chunk.subarray(i, i + chunkSize)); }, } : input, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index e3249c70882e..acbf817462c1 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -899,7 +899,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( let specialFlags = false; let recordStart = true; let skipped = false; - let skippedRecord: number[] = []; + let skippedRecord: Uint8Array[] = []; const skippedPaths: string[] = []; yield* vcsProcess.run({ operation, @@ -909,27 +909,35 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( env: commitEnv, maxOutputBytes: 4_096, outputMode: "truncate", + // Only tags and sparse skip-worktree paths matter; scan NUL-delimited records. onStdoutChunk: (chunk) => { - for (const byte of chunk) { - if (recordStart) skipped = byte === 83; + let start = 0; + while (start < chunk.length) { + const end = chunk.indexOf(0, start); + if (recordStart) { + const tag = chunk[start]!; + skipped = tag === 83; + if ((tag >= 97 && tag <= 122) || (!sparseCheckout && tag === 83)) { + specialFlags = true; + } + } if (skipped && sparseCheckout) { - if (byte !== 0) skippedRecord.push(byte); - else { - if (skippedRecord.at(-1) !== 47) { - const name = Buffer.from(skippedRecord).subarray(2); + skippedRecord.push( + Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)), + ); + if (end !== -1) { + const record = Buffer.concat(skippedRecord); + if (record.at(-1) !== 47) { + const name = record.subarray(2); if (!NodeBuffer.isUtf8(name)) specialFlags = true; else skippedPaths.push(name.toString("utf8")); } skippedRecord = []; } } - if ( - recordStart && - ((byte >= 97 && byte <= 122) || (!sparseCheckout && byte === 83)) - ) { - specialFlags = true; - } - recordStart = byte === 0; + recordStart = end !== -1; + if (!recordStart) break; + start = end + 1; } }, }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4e0dbdffe4b6..ea57e87b5893 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -405,6 +405,7 @@ describe("pullCurrentBranch", () => { const driver = yield* GitVcsDriver.GitVcsDriver; const fs = yield* FileSystem.FileSystem; const { cwd, updater, initialBranch, remoteHead } = yield* cloneBehindRemote; + yield* git(cwd, ["tag", initialBranch]); yield* git(updater, ["push", "origin", "HEAD:refs/heads/other"]); const remoteName = customMapping ? "team/upstream" : "origin"; const refPrefix = customMapping @@ -1003,6 +1004,45 @@ describe("rejected git refs", () => { }); it.layer(layerTest)("GitVcsDriver core integration", (it) => { + it.effect("keeps large change sets available for status and commit preparation", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + const files = Array.from( + { length: 6_000 }, + (_, index) => `file-${String(index).padStart(5, "0")}-${"x".repeat(160)}.txt`, + ); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "before\n"), { + concurrency: 16, + discard: true, + }); + yield* git(cwd, ["add", "."]); + const unborn = yield* driver.statusDetailsLocal(cwd); + assert.lengthOf(unborn.workingTree.files, files.length); + assert.equal(unborn.workingTree.insertions, files.length); + const initial = yield* driver.commit(cwd, "initial", ""); + assert.match(initial.commitSha, /^[a-f0-9]{40}$/); + assert.isFalse((yield* driver.statusDetailsLocal(cwd)).hasWorkingTreeChanges); + yield* Effect.forEach(files, (file) => fs.writeFileString(path.join(cwd, file), "after\n"), { + concurrency: 16, + discard: true, + }); + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasWorkingTreeChanges); + assert.lengthOf(status.workingTree.files, files.length); + assert.equal(status.workingTree.insertions, files.length); + assert.equal(status.workingTree.deletions, files.length); + const context = yield* driver.prepareCommitContext(cwd, undefined, { includePatch: false }); + assert.isNotNull(context); + assert.isAbove(context!.stagedSummary.length, 1_000_000); + assert.lengthOf(context!.stagedSummary.split("\n"), files.length); + }), + ); describe("structured errors", () => { it.effect("does not retain git arguments or stderr in command failures", () => Effect.gen(function* () { @@ -2304,6 +2344,22 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("repository status", () => { + it.effect("reads the current branch by name when a tag shares it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["checkout", "-b", "release"]); + yield* git(cwd, ["tag", "release"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const remoteStatus = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + assert.equal(remoteStatus.branch, "release"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const branchRange = preview.sources.find((source) => source.kind === "branch-range"); + assert.equal(branchRange?.headRef, "release"); + }), + ); + it.effect("does not open a project folder laid out as a bare repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3496,6 +3552,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps generated worktree folders short without changing long branch names", () => + Effect.gen(function* () { + const pathService = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const worktreesDirectory = yield* makeTmpDir("custom-worktrees-"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const folders = new Set(); + const branches = [ + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"c".repeat(90)}`, + `feature/${"a".repeat(90)}/${"b".repeat(90)}/${"d".repeat(90)}`, + `feature/${"a".repeat(40)}/5fac`, + `feature/${"a".repeat(40)}/18828`, + `${"a".repeat(22)}๐Ÿ˜€/${"b".repeat(30)}`, + "z".repeat(40), + "z".repeat(41), + ]; + for (const branch of branches) { + const created = yield* driver.createWorktree( + { cwd, path: null, refName: initialBranch, newRefName: branch }, + { worktreesDirectory }, + ); + const folder = pathService.basename(created.worktree.path); + assert.isAtMost(folder.length, 40); + if (branch.length <= 40) assert.equal(folder, branch); + assert.equal(yield* git(created.worktree.path, ["branch", "--show-current"]), branch); + assert.include( + yield* git(cwd, ["-c", "core.quotePath=false", "worktree", "list", "--porcelain"]), + `worktree ${created.worktree.path}`, + ); + folders.add(folder); + } + assert.equal(folders.size, branches.length); + }), + ); + it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index aec971ce53eb..49b0f6139652 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -518,6 +518,15 @@ function classifyGitFailure(stderr: string): GitCommandFailureReason | null { return null; } +// `--short` and `--abbrev-ref` print `heads/` when a tag shares the branch's name, so +// branch reads take the full ref and strip the prefix themselves. +function branchNameFromHeadRef(value: string): string | null { + const ref = value.trim(); + return ref.startsWith("refs/heads/") && ref.length > "refs/heads/".length + ? ref.slice("refs/heads/".length) + : null; +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -1416,7 +1425,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -1447,11 +1456,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""), Effect.orElseSucceed(() => undefined), ); - const currentBranchOutput = currentBranchResult.stdout.trim(); const currentBranch = - currentBranchResult.exitCode === 0 && currentBranchOutput.length > 0 - ? currentBranchOutput - : null; + currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null; return { gitCommonDir, @@ -1942,7 +1948,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchResult = yield* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetailsRemote.branch", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], + ["rev-parse", "--symbolic-full-name", "HEAD"], { allowNonZeroExit: true }, ).pipe( Effect.catchTags({ @@ -1964,7 +1970,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetailsRemote.branch", cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], + args: ["rev-parse", "--symbolic-full-name", "HEAD"], }), detail: "Git branch lookup failed.", exitCode: branchResult.exitCode, @@ -1976,12 +1982,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const branchValue = yield* runGitStdout( "GitVcsDriver.statusDetailsRemote.unbornBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], ); - branch = branchValue.trim() || null; + branch = branchNameFromHeadRef(branchValue); } else { - const branchValue = branchResult.stdout.trim(); - branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + branch = branchNameFromHeadRef(branchResult.stdout); } const currentUpstreamRef = yield* readCurrentUpstreamRef(cwd); let upstreamRef = @@ -2096,6 +2101,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusArgs, { allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }, ).pipe( Effect.catchTags({ @@ -2136,24 +2142,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.statusDetails.numstat", cwd, ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], - { allowNonZeroExit: true }, + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe( Effect.flatMap((result) => { if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); if (isUnbornHeadStderr(result.stderr)) { return Effect.map( Effect.all([ - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ - "diff-files", - "--numstat", - "-z", - ]), - runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ - "diff", - "--cached", - "--numstat", - "-z", - ]), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn", + cwd, + ["diff-files", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.statusDetails.numstat.unborn.staged", + cwd, + ["diff", "--cached", "--numstat", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ), ]), ([unstagedStdout, stagedStdout]) => [ ...parseReviewNumstat(stagedStdout), @@ -2443,7 +2450,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "GitVcsDriver.prepareCommitContext.stagedSummary", cwd, ["diff", "--cached", "--name-status"], - { env }, + { env, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ).pipe(Effect.map((stdout) => stdout.trim())); if (stagedSummary.length === 0) { return null; @@ -2507,6 +2514,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); @@ -2785,9 +2793,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdout( "GitVcsDriver.pullCurrentBranch.currentBranch", cwd, - ["symbolic-ref", "--quiet", "--short", "HEAD"], + ["symbolic-ref", "--quiet", "HEAD"], true, - ).pipe(Effect.map((stdout) => stdout.trim())), + ).pipe(Effect.map(branchNameFromHeadRef)), runGitStdout("GitVcsDriver.pullCurrentBranch.currentSha", cwd, ["rev-parse", "HEAD"]).pipe( Effect.map((stdout) => stdout.trim()), ), @@ -3810,7 +3818,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.refName); yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; @@ -3829,7 +3836,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings โ†’ Worktrees.`, }); } - worktreePath = path.join(parentDir, repoName, sanitizedBranch); + let directoryName = targetBranch.replace(/\//g, "-"); + // Leave room for files beneath the worktree on Windows, regardless of branch length. + if (directoryName.length > 40) { + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(targetBranch)) + .pipe(Effect.orDie); + // Avoid splitting a surrogate pair, which Git would list back as a different path. + const prefix = directoryName.slice(0, 23).replace(/[\uD800-\uDBFF]$/, ""); + directoryName = `${prefix}-${Hex.encode(digest).slice(0, 16)}`; + } + worktreePath = path.join(parentDir, repoName, directoryName); } if (input.newRefName) { // Kept apart from the checkout so the shared-config lock only covers the tracking write. diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index bdf5dfff6c4c..cc1b6d24e387 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -12,16 +12,17 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; +import * as Scheduler from "effect/Scheduler"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import type { BackgroundScope, VcsStatusLocalResult, VcsStatusRemoteResult, - VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitCommandError, GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -58,16 +59,11 @@ const remoteStatusWithPr: VcsStatusRemoteResult = { title: "[codex] Rewrite client connection architecture", url: "https://github.com/pingdotgg/t3code/pull/2978", baseRef: "main", - headRef: "codex/connection-state-audit", + headRef: "upstream-topic", state: "open", }, }; -const baseStatus: VcsStatusResult = { - ...baseLocalStatus, - ...baseRemoteStatus, -}; - function layerTestFor(state: { currentLocalStatus: VcsStatusLocalResult; currentRemoteStatus: VcsStatusRemoteResult | null; @@ -76,12 +72,19 @@ function layerTestFor(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + remoteStatusError?: GitManagerError; backgroundWorkEnabled?: boolean; automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; + fileSystem?: FileSystem.FileSystem; + pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { return VcsStatusBroadcaster.layer.pipe( - Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + state.fileSystem + ? Layer.succeed(FileSystem.FileSystem, state.fileSystem) + : NodeServices.layer, + ), Layer.provide(layerBackgroundPolicy(() => state.backgroundWorkEnabled !== false)), Layer.provide( Layer.succeed( @@ -96,16 +99,22 @@ function layerTestFor(state: { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), remoteStatus: (_input, options) => - Effect.suspend(() => state.beforeRemoteStatus ?? Effect.void).pipe( - Effect.andThen( - Effect.sync(() => { - state.remoteStatusCalls += 1; - state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); - return state.currentRemoteStatus; - }), - ), - ), + Effect.suspend(() => { + return (state.beforeRemoteStatus ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + state.remoteStatusCalls += 1; + state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + return state.currentRemoteStatus; + }), + ), + Effect.tap(() => + state.remoteStatusError ? Effect.fail(state.remoteStatusError) : Effect.void, + ), + ); + }), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -119,6 +128,7 @@ function layerTestFor(state: { state.localInvalidationCalls += 1; state.remoteInvalidationCalls += 1; }), + ...(state.pullCurrentBranch ? { pullCurrentBranch: state.pullCurrentBranch } : {}), }), ), ); @@ -171,7 +181,7 @@ describe("VcsStatusBroadcaster", () => { Stream.tap((event) => event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void, ), - Stream.take(2), + Stream.take(3), Stream.runCollect, Effect.forkScoped, ); @@ -183,12 +193,11 @@ describe("VcsStatusBroadcaster", () => { assert.isNull(refreshed.pr); assert.equal(state.localInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.remoteInvalidationCalls, 1); - assert.deepStrictEqual((yield* Fiber.join(events))[1], { - _tag: "snapshot", - local: state.currentLocalStatus, - remote: null, - }); + assert.equal(state.remoteInvalidationCalls, 2); + assert.deepStrictEqual((yield* Fiber.join(events)).slice(1), [ + { _tag: "remoteUpdated", remote: null }, + { _tag: "localUpdated", local: state.currentLocalStatus }, + ]); assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); const snapshot = yield* broadcaster .streamStatus({ cwd: "/repo", includeRemote: false }) @@ -220,7 +229,7 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(refreshed, initial); assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo" }), refreshed); assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 1); assert.equal(state.remoteStatusCalls, 1); }).pipe(Effect.provide(layerTestFor(state))); }, @@ -288,6 +297,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(localStatus), + currentBranch: () => Effect.succeed(localStatus.refName), remoteStatus: () => Effect.succeed(remoteStatus), invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -350,7 +360,7 @@ describe("VcsStatusBroadcaster", () => { const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(refreshed, remoteStatusWithPr); assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 2); state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next" }; state.currentRemoteStatus = baseRemoteStatus; @@ -358,6 +368,11 @@ describe("VcsStatusBroadcaster", () => { const refreshedBranch = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(refreshedBranch, baseRemoteStatus); assert.equal(state.remoteStatusCalls, 3); + const refreshedStatus = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(refreshedStatus.refName, "feature/next"); + assert.isNull(refreshedStatus.pr); + assert.equal(state.remoteStatusCalls, 3); + assert.equal(state.remoteInvalidationCalls, 3); }).pipe(Effect.provide(layerTestFor(state))); }); @@ -371,6 +386,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), remoteStatus: () => Effect.gen(function* () { remoteReads += 1; @@ -416,6 +432,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), + currentBranch: () => Effect.succeed(baseLocalStatus.refName), remoteStatus: () => Effect.gen(function* () { remoteReads += 1; @@ -426,6 +443,8 @@ describe("VcsStatusBroadcaster", () => { } return remoteStatusWithPr; }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, invalidateStatus: () => Effect.void, }), ), @@ -446,7 +465,7 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); - it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { + it.effect("keeps the published local snapshot when a refresh remote read fails", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -466,6 +485,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: () => Effect.suspend(() => { state.remoteStatusCalls += 1; @@ -511,10 +531,18 @@ describe("VcsStatusBroadcaster", () => { state.failRemoteStatus = true; const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + const snapshot = yield* Stream.runHead( + broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }), + ); assert.isTrue(Exit.isFailure(refreshExit)); - assert.deepStrictEqual(cached, baseStatus); + assert.isTrue(Option.isSome(snapshot)); + const event = Option.getOrThrow(snapshot); + assert.equal(event._tag, "snapshot"); + if (event._tag === "snapshot") { + assert.equal(event.local.refName, "feature/partial-refresh"); + assert.isNull(event.remote); + } }).pipe(Effect.provide(layerTest)); }); @@ -541,6 +569,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: (input) => Effect.sync(() => { seenCwds.push(input.cwd); @@ -615,6 +644,256 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state))); }); + it.effect("publishes local status while an earlier lookup holds the remote lock", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(published, event.local); + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const earlier = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(started); + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const completed = yield* Deferred.make(); + const refresh = yield* broadcaster + .refreshStatus("/repo") + .pipe(Effect.ensuring(Deferred.succeed(completed, undefined)), Effect.forkScoped); + + assert.deepStrictEqual(yield* Deferred.await(published), state.currentLocalStatus); + assert.isFalse(yield* Deferred.isDone(completed)); + assert.isFalse(yield* Deferred.isDone(release)); + state.currentRemoteStatus = remoteStatusWithPr; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(earlier); + const status = yield* Fiber.join(refresh); + assert.isTrue(status.hasWorkingTreeChanges); + assert.deepStrictEqual(status.pr, remoteStatusWithPr.pr); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + + it.effect("publishes local changes before a remote error and propagates that error", () => { + const remoteError = new GitManagerError({ + operation: "VcsStatusBroadcaster.remoteStatus", + cwd: "/repo", + detail: "The controlled remote lookup failed.", + }); + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const published = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(published, event.local); + return Effect.void; + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const completed = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + state.remoteStatusError = remoteError; + const refresh = yield* broadcaster + .refreshStatus("/repo") + .pipe( + Effect.result, + Effect.ensuring(Deferred.succeed(completed, undefined)), + Effect.forkScoped, + ); + yield* Deferred.await(started); + assert.isFalse(yield* Deferred.isDone(completed)); + const pending = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: "/repo", includeRemote: false })), + ); + assert.equal(pending._tag, "snapshot"); + if (pending._tag === "snapshot") assert.isTrue(pending.local.hasWorkingTreeChanges); + yield* Deferred.succeed(release, undefined); + const result = yield* Fiber.join(refresh); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.strictEqual(result.failure, remoteError); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.isTrue(cached.hasWorkingTreeChanges); + assert.deepStrictEqual(cached.pr, remoteStatusWithPr.pr); + assert.deepStrictEqual(yield* Deferred.await(published), state.currentLocalStatus); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + + it.effect.each(["getStatus", "refreshStatus", "autoPull", "autoPullFailure"] as const)( + "%s keeps response parts consistent when local state changes after the remote cache write", + (operation) => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + const autoPull = operation === "autoPull" || operation === "autoPullFailure"; + if (autoPull) { + state.currentLocalStatus = { ...baseLocalStatus, isDefaultRef: true }; + state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 0, behindCount: 5 }; + state.pullCurrentBranch = () => + operation === "autoPullFailure" + ? Effect.fail( + new GitCommandError({ + operation: "test.pull", + command: "pull", + cwd: "/repo", + detail: "Pull failed.", + }), + ) + : Effect.succeed({ + status: "pulled", + refName: "feature/status-broadcast", + upstreamRef: "origin/main", + }); + } + const layerTest = layerTestFor(state).pipe( + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: () => Effect.succeed(autoPull), + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + let switched = false; + let remoteWritten = false; + const writeCompleted = yield* Deferred.make(); + const localRefresh = yield* Effect.gen(function* () { + yield* Deferred.await(writeCompleted); + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next-status" }; + yield* broadcaster.refreshLocalStatus("/repo"); + switched = true; + }).pipe(Effect.forkScoped); + const scheduler = new Scheduler.MixedScheduler(); + const shouldYield = scheduler.shouldYield.bind(scheduler); + scheduler.shouldYield = (fiber) => (remoteWritten && !switched) || shouldYield(fiber); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (time, exit) => { + end(time, exit); + if ( + options.name !== "VcsStatusBroadcaster.updateCachedRemoteStatus" || + remoteWritten + ) { + return; + } + // Yield this reader after its write until the local refresh finishes. + remoteWritten = true; + Deferred.doneUnsafe(writeCompleted, Effect.void); + }; + return span; + }, + }); + const status = yield* ( + operation === "getStatus" + ? broadcaster.getStatus({ cwd: "/repo" }) + : broadcaster.refreshStatus("/repo") + ).pipe(Effect.withTracer(tracer), Effect.provideService(Scheduler.Scheduler, scheduler)); + yield* Fiber.join(localRefresh); + assert.isTrue(switched); + assert.equal(status.refName, "feature/next-status"); + assert.isNull(status.pr); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }).pipe(Effect.provide(layerTest), Effect.scoped); + }, + ); + + it.effect("recovers a discarded branch lookup on the next permitted turn-end refresh", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const lookup = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(started); + state.currentLocalStatus = { ...baseLocalStatus, refName: "feature/next-status" }; + yield* Deferred.succeed(release, undefined); + const discarded = yield* Fiber.join(lookup); + assert.equal(discarded.refName, state.currentLocalStatus.refName); + assert.isNull(discarded.pr); + assert.equal(discarded.aheadCount, 0); + assert.equal(discarded.behindCount, 0); + const snapshot = Option.getOrThrow( + yield* Stream.runHead(broadcaster.streamStatus({ cwd: "/repo", includeRemote: false })), + ); + assert.equal(snapshot._tag, "snapshot"); + if (snapshot._tag === "snapshot") assert.isNull(snapshot.remote); + + state.beforeRemoteStatus = Effect.void; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + pr: { ...remoteStatusWithPr.pr!, number: 2979, headRef: "feature/next-status" }, + }; + state.backgroundWorkEnabled = false; + yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.equal(state.remoteStatusCalls, 1); + state.backgroundWorkEnabled = true; + const recovered = yield* broadcaster.refreshPullRequestStatus("/repo"); + assert.deepStrictEqual(recovered, state.currentRemoteStatus); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(cached.refName, state.currentLocalStatus.refName); + assert.deepStrictEqual(cached.pr, state.currentRemoteStatus.pr); + assert.equal(cached.aheadCount, 2); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("releases remote demand while passive observers remain", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -636,6 +915,7 @@ describe("VcsStatusBroadcaster", () => { state.localStatusCalls += 1; return state.currentLocalStatus; }), + currentBranch: () => Effect.succeed(state.currentLocalStatus.refName), remoteStatus: () => Effect.sync(() => { state.remoteStatusCalls += 1; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 3cbcb3546e9d..8ce1b225d123 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -130,9 +130,13 @@ interface CachedValue { readonly value: T; } +interface CachedRemoteStatus extends CachedValue { + readonly branch: string | null; +} + interface CachedVcsStatus { readonly local: CachedValue | null; - readonly remote: CachedValue | null; + readonly remote: CachedRemoteStatus | null; } interface ActiveRemotePoller { @@ -261,106 +265,51 @@ export const make = Effect.gen(function* () { const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local }; + const [localChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + const remote = previous.remote?.branch === local.refName ? previous.remote : null; + return [ + [previous.local?.fingerprint !== nextLocal.fingerprint, previous.remote !== remote], + new Map(cache).set(cwd, { local: nextLocal, remote }), + ] as const; }); - - if (options?.publish && shouldPublish) { + if (options?.publish && remoteCleared) { yield* PubSub.publish(changesPubSub, { cwd, - event: { - _tag: "localUpdated", - local, - }, + event: { _tag: "remoteUpdated", remote: null }, + }); + } + if (options?.publish && localChanged) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { _tag: "localUpdated", local }, }); } - return local; }, ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + function* (cwd: string, remote: CachedRemoteStatus | null, options?: { publish?: boolean }) { + const [changed, value] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + const next = remote?.branch === previous.local?.value.refName ? remote : null; + return [ + [previous.remote?.fingerprint !== next?.fingerprint, next?.value ?? null], + new Map(cache).set(cwd, { ...previous, remote: next }), + ] as const; }); - - if (options?.publish && shouldPublish) { + if (options?.publish && changed) { yield* PubSub.publish(changesPubSub, { cwd, - event: { - _tag: "remoteUpdated", - remote, - }, + event: { _tag: "remoteUpdated", remote: value }, }); } - - return remote; + return value; }, ); - const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, - local: VcsStatusLocalResult, - remote: VcsStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - local: nextLocal, - remote: nextRemote, - }); - return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, - nextCache, - ] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "snapshot", - local, - remote, - }, - }); - } - - return mergeGitStatusParts(local, remote); - }); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( cwd: string, ) { @@ -378,6 +327,14 @@ export const make = Effect.gen(function* () { return yield* loadLocalStatus(cwd); }); + const getStatusParts = Effect.fn("VcsStatusBroadcaster.getStatusParts")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return { local: cached.local.value, remote: cached.remote?.value ?? null }; + } + return { local: yield* loadLocalStatus(cwd), remote: null }; + }); + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( @@ -388,21 +345,16 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } + yield* getOrLoadLocalStatus(cwd); return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const latest = yield* getCachedStatus(cwd); const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); - const [local, remote] = yield* Effect.all( - [ - latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), - latest?.remote - ? Effect.succeed(latest.remote.value) - : workflow.remoteStatus({ cwd }, { refreshUpstream }), - ], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote); + const remote = latest?.remote ?? (yield* readRemoteStatus(cwd, { refreshUpstream })); + yield* updateCachedRemoteStatus(cwd, remote); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); }); @@ -422,11 +374,30 @@ export const make = Effect.gen(function* () { return yield* refreshLocalStatusCore(cwd); }); + const readRemoteStatus = Effect.fn("VcsStatusBroadcaster.readRemoteStatus")(function* ( + cwd: string, + options?: Parameters[1], + ) { + const branch = yield* workflow.currentBranch(cwd); + // GitManager's remote TTL cache is keyed by cwd, not the checked-out branch. + yield* workflow.invalidateRemoteStatus(cwd); + const value = yield* workflow.remoteStatus({ cwd }, options); + const currentBranch = yield* workflow.currentBranch(cwd); + const cached = yield* getCachedStatus(cwd); + if (branch !== currentBranch || cached?.local?.value.refName !== currentBranch) { + yield* refreshLocalStatusCore(cwd); + } + return branch === currentBranch + ? { branch, value, fingerprint: fingerprintStatusPart(value) } + : null; + }); + const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( cwd: string, - remote: VcsStatusRemoteResult | null, + remoteRead: CachedRemoteStatus | null, policyCwds: ReadonlyArray, ) { + const remote = remoteRead?.value ?? null; let pullAttempted = false; return yield* Effect.gen(function* () { const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { @@ -444,17 +415,21 @@ export const make = Effect.gen(function* () { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); - if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; + if ( + !local.isRepo || + !local.isDefaultRef || + local.hasWorkingTreeChanges || + local.refName !== remoteRead?.branch + ) + return null; pullAttempted = true; yield* workflow.pullCurrentBranch(cwd); yield* workflow.invalidateStatus(cwd); - const [refreshedLocal, refreshedRemote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], - { concurrency: "unbounded" }, - ); - yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); - return { local: refreshedLocal, remote: refreshedRemote }; + yield* refreshLocalStatusCore(cwd); + const refreshedRemote = yield* readRemoteStatus(cwd, { refreshUpstream: false }); + yield* updateCachedRemoteStatus(cwd, refreshedRemote, { publish: true }); + return yield* getStatusParts(cwd); }).pipe( Effect.catch((error) => Effect.gen(function* () { @@ -463,11 +438,11 @@ export const make = Effect.gen(function* () { errorTag: error._tag, }); if (!pullAttempted) return null; - // Pull can change local state before failing; propagate reconciliation failures instead of publishing stale status. - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - yield* updateCachedStatus(cwd, local, remote, { publish: true }); - return { local, remote }; + // A pull or its follow-up reads can fail after local state has changed. + // Let a failed reconciliation reach the caller instead of publishing old status. + yield* refreshLocalStatusCore(cwd); + yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); + return yield* getStatusParts(cwd); }), ), ); @@ -483,15 +458,13 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; - const remote = yield* workflow.remoteStatus({ cwd }, options); + const remoteRead = yield* readRemoteStatus(cwd, options); + const remote = remoteRead?.value; const pulled = options?.refreshUpstream === false ? null - : yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + : yield* maybeAutoPull(cwd, remoteRead, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; if ( remote && @@ -502,7 +475,7 @@ export const make = Effect.gen(function* () { ) { yield* refreshLocalStatusCore(cwd); } - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); }); @@ -511,35 +484,41 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const previous = options?.localOnly ? yield* getCachedStatus(cwd) : null; + const local = yield* refreshLocalStatusCore(cwd); if (options?.localOnly) { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - const previous = yield* getCachedStatus(cwd); - if (previous?.local?.value.refName !== local.refName) { + if (previous?.local?.value.refName !== local.refName || !previous?.remote) { yield* workflow.invalidateRemoteStatus(cwd); - return yield* updateCachedStatus(cwd, local, null, { publish: true }); + yield* updateCachedRemoteStatus( + cwd, + { branch: local.refName, fingerprint: fingerprintStatusPart(null), value: null }, + { publish: true }, + ); } - yield* updateCachedLocalStatus(cwd, local, { publish: true }); - return mergeGitStatusParts(local, previous?.remote?.value ?? null); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); } - // Full invalidation also bypasses GitManager's slow PR cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); + // Bump the PR lookup epoch after earlier remote writers have finished. yield* workflow.invalidateStatus(cwd); - // Local after remote: the fetch can move the base that the Changes totals compare with. - const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream }); + const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); + // Fetch can move the base used for local Changes totals. const local = yield* workflow.localStatus({ cwd }); + yield* updateCachedLocalStatus(cwd, local, { publish: true }); // Without a fetch, the cached behind count cannot justify an automatic pull. - const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remote, [rawCwd]) : null; + const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); }), ); }); @@ -551,7 +530,7 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); - if (cached?.remote?.value == null) return null; + if (!cached?.local || cached.remote?.value === null) return null; const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; const shouldRefresh = (yield* Effect.forEach( @@ -561,12 +540,11 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; - // Re-resolve the branch; cached PRs may belong to a checkout's previous branch. - const remote = yield* workflow.remoteStatus( - { cwd }, - { refreshUpstream: false, refreshMissingPullRequest: true }, - ); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + const remoteRead = yield* readRemoteStatus(cwd, { + refreshUpstream: false, + refreshMissingPullRequest: true, + }); + return yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); }), ); }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index b09393ab6043..96f6adcafa14 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -49,6 +49,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; @@ -413,12 +414,23 @@ it.effect("shell snapshots omit idle children and older settled threads only on Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), Effect.provideService(ThreadManagement.ThreadManagementService, { - readShellSnapshot: () => + listActiveShellIndex: () => + Effect.succeed( + threads.map((thread) => ({ + ...thread, + pinnedAt: null, + snoozedUntil: null, + mayBeLive: thread.activityRunStatus !== null, + })), + ), + readShellSnapshot: (options?: { readonly threadIds?: ReadonlyArray }) => Effect.succeed( Effect.succeed({ schemaVersion: 1, snapshotSequence: 0, - threads, + threads: threads.filter( + (thread) => options?.threadIds?.includes(thread.id) ?? true, + ), archivedThreads: [], }), ), @@ -601,6 +613,7 @@ it.effect( scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), @@ -777,6 +790,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ getDescriptor: Effect.succeed({ environmentId: EnvironmentId.make("config-test"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 682cc794a88e..86d89197af0b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -127,7 +127,6 @@ import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { - buildActiveShellSnapshot, bufferShellLiveStream, coalesceShellApplicationEvents, composeShellStreamWithEnrichment, @@ -137,7 +136,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -226,6 +225,7 @@ import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; +import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -1023,21 +1023,23 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc }, ); const loadSnapshot = Effect.fn("ws.orchestrationV2.loadShellSnapshot")(function* () { - const base = buildActiveShellSnapshot( - yield* loadShellSnapshotParts({ - sql, - readThreads: threadManagement.readShellSnapshot({ location: "active" }), - listProjects: projects.listShells(), - latestSequence: applicationEvents.latestApplicationSequence, - }), - ); - const forSubscriber = shellSnapshotForSubscriber(base, { - omitIdleSubagents: input.omitIdleSubagents === true, - omitOlderSettled: input.omitOlderSettled === true, - now: yield* DateTime.now, + const forSubscriber = yield* loadSubscriberShellSnapshot({ + sql, + listShellIndex: threadManagement.listActiveShellIndex(), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ + location: "active", + ...(threadIds === undefined ? {} : { threadIds }), + }), + listProjects: projects.listShells(), + latestSequence: applicationEvents.latestApplicationSequence, + options: { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + }, }); subagentThreadIds = forSubscriber.subagentThreadIds; - const enriched = yield* enrichProjectShells(base.projects); + const enriched = yield* enrichProjectShells(forSubscriber.snapshot.projects); return { snapshot: { ...forSubscriber.snapshot, @@ -1252,6 +1254,7 @@ export const layerWsRpc = ( const projectStore = yield* ProjectStore.ProjectStoreV2; const projectService = yield* ProjectService.ProjectService; const managedFolders = yield* ManagedProjectFolders.ManagedProjectFolders; + const scratchThreadPromotion = yield* ScratchThreadPromotion.ScratchThreadPromotion; const eventLoopMonitor = yield* EventLoopMonitor.EventLoopMonitor; const threadSearch = yield* ThreadSearch.ThreadSearch; const home = yield* HomeService.HomeService; @@ -1809,6 +1812,7 @@ export const layerWsRpc = ( threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -2739,6 +2743,14 @@ export const layerWsRpc = ( (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), ), ), + [WS_METHODS.projectsCreateFromThread]: (input) => + scratchThreadPromotion + .promote(input) + .pipe( + Effect.mapError( + (cause) => new OrchestrationDispatchCommandError({ message: cause.message, cause }), + ), + ), [WS_METHODS.projectCloneCancel]: (input) => projectCloneTracker.cancel(input.projectId).pipe(Effect.map((applied) => ({ applied }))), [WS_METHODS.projectCloneRetry]: (input) => @@ -3043,6 +3055,7 @@ export const layerWsRpc = ( [WS_METHODS.deviceConfigure]: (input) => deviceService.configure(input), [WS_METHODS.deviceTestHost]: (input) => deviceService.testHost(input), [WS_METHODS.deviceEasAccount]: (input) => deviceService.easAccount(input), + [WS_METHODS.deviceSimlockIdentity]: (input) => deviceService.simlockIdentity(input), [WS_METHODS.deviceList]: (input) => input.inspectOnly && !input.updateTool ? deviceService.inspect diff --git a/apps/web/package.json b/apps/web/package.json index 729d44f8ea6c..2ee8fe51a06a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.3", + "version": "0.1.5", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/cloud/guestServerUpdate.test.tsx b/apps/web/src/cloud/guestServerUpdate.test.tsx index c158172eb415..c4221098d784 100644 --- a/apps/web/src/cloud/guestServerUpdate.test.tsx +++ b/apps/web/src/cloud/guestServerUpdate.test.tsx @@ -39,6 +39,7 @@ vi.mock("~/state/use-atom-command", () => ({ // The guest's own session grants nothing: a manager upgrade must not need it. const unauthorizedSession = AsyncResult.success({ authenticated: false }); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => unauthorizedSession })); +vi.mock("~/state/entities", () => ({ useServerConfigs: () => new Map() })); vi.mock("~/state/session", () => ({ environmentSession: { sessionStateAtom: () => "session" }, })); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 345bde84976c..745e69087cb1 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -328,6 +328,22 @@ describe("ChatMarkdown headings", () => { }); }); +describe("ChatMarkdown inline code selection", () => { + it("selects the whole inline code span on double-click, but only a word in fenced code", async () => { + setup(); + await using screen = await renderText( + "Run `migration-gate --dry-run` first.\n\n```txt\nfenced-block word\n```", + ); + await userEvent.dblClick(screen.getByText("migration-gate --dry-run")); + expect(window.getSelection()?.toString()).toBe("migration-gate --dry-run"); + + window.getSelection()?.removeAllRanges(); + const fenced = screen.container.querySelector("pre code")!; + await userEvent.dblClick(fenced, { position: { x: 4, y: 4 } }); + expect(window.getSelection()?.toString()).toBe("fenced"); + }); +}); + describe("ChatMarkdown Windows file links", () => { const environmentId = EnvironmentId.make("env-windows"); const renderWindows = (text: string, parseRawHtml: boolean) => diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 32f15bcf542d..6a8f80d6d107 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,6 +74,7 @@ import React, { Suspense, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, + type MouseEvent as ReactMouseEvent, use, memo, useLayoutEffect, @@ -942,6 +943,11 @@ function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { }; } +// Native word selection stops at hyphens and dots, so a double-click takes the whole span. +function selectInlineCode(event: ReactMouseEvent) { + window.getSelection()?.selectAllChildren(event.currentTarget); +} + const CHAT_MARKDOWN_COMPONENTS = { h1: markdownHeadingRenderer(1), h2: markdownHeadingRenderer(2), @@ -1334,6 +1340,11 @@ const CHAT_MARKDOWN_COMPONENTS = { inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } + return ( + + {children} + + ); } return ( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c7c0f77bc97f..bef8f7ab405f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,6 +3,7 @@ import { deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { latestUnheldRun, @@ -218,7 +219,7 @@ export function shouldOpenProactiveTurnDiff(input: { } export function resolveProactiveTurnDiffAction(input: { - checkpoint: Pick | undefined; + checkpoint: Pick | undefined; isGitRepo: boolean | undefined; }): "defer" | "ignore" | "open" { if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; @@ -230,11 +231,8 @@ export function resolveProactiveTurnDiffAction(input: { ) { return "ignore"; } - const changedLines = input.checkpoint.files.reduce( - (total, file) => total + file.additions + file.deletions, - 0, - ); - return input.checkpoint.files.length >= 3 || changedLines >= 50 ? "open" : "ignore"; + const totals = checkpointFileTotals(input.checkpoint); + return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } /** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dbf6de538705..7d232da2144d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { queuedRunEditBlockedReason } from "@t3tools/client-runtime/queued-run-edit"; import { ScrollToEndButton, WorkspaceFileDropOverlay } from "./chat/TimelineOverlays"; import { usePasteToFocusComposer } from "./chat/usePasteToFocusComposer"; @@ -191,6 +192,7 @@ import { newMessageId } from "~/lib/utils"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, + resolveProjectDefaultProviderModelSelection, shouldShowInstanceBadge, } from "../providerInstances"; import { @@ -307,7 +309,8 @@ import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullReq import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { @@ -403,6 +406,8 @@ type ChatViewProps = draftId: DraftId; }; +const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -591,6 +596,9 @@ export default function ChatView(props: ChatViewProps) { ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null) : null; }); + const composerModelSelectionExplicit = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.modelSelectionExplicit === true, + ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; @@ -756,15 +764,37 @@ export default function ChatView(props: ChatViewProps) { }); } } + const nowMinute = useNowMinute(); + const draftDefaultModelSelection = draftThread + ? resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection + : null; + const draftModelResolution = draftThread + ? resolveNewThreadModelSelection({ + defaultSelection: + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + draftThread.projectId, + ), + fallbackSelection: settings.fallbackModelSelection, + providers: providersByEnvironment.get(draftThread.environmentId) ?? [], + settings, + projectId: draftThread.projectId, + now: Date.parse(`${nowMinute}:00Z`), + }) + : null; + const draftFallback = draftModelResolution?.fallback ? draftModelResolution : null; const localDraftThread = draftThread ? buildLocalDraftThread( threadId, draftThread, - resolveProjectSettings( - settings, - fallbackDraftProject?.id ?? null, - fallbackDraftProject ?? undefined, - ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + (draftFallback ? draftFallback.selection : draftDefaultModelSelection) ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined; const isServerThread = serverThread !== null; @@ -1057,7 +1087,10 @@ export default function ChatView(props: ChatViewProps) { const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; const { projectCloneBannerItem, projectCloneSendBlockReason } = useProjectCloneBanner(activeProjectRef); - const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; + const activeProjectDefaultModelSelection = + !isServerThread && draftFallback + ? draftFallback.selection + : activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); }; @@ -1674,6 +1707,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed, ]); + const [dismissedFallbackWindow, setDismissedFallbackWindow] = useLocalStorage( + `t3code:fallback-notice-dismissed:${environmentId}`, + null, + DismissedFallbackWindowSchema, + ); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -2359,7 +2397,6 @@ export default function ChatView(props: ChatViewProps) { closeRightPanelSurface, }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const nowMinute = useNowMinute(); const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2540,6 +2577,51 @@ export default function ChatView(props: ChatViewProps) { }) : null; const composerBannerItems = (() => { + const fallbackSelection = draftFallback?.selection; + const defaultSelection = + draftDefaultModelSelection ?? + resolveProjectDefaultProviderModelSelection( + providerStatuses, + settings, + draftThread?.projectId ?? null, + ); + const fallback = + !isServerThread && + multipleModelSelections === null && + !composerModelSelectionExplicit && + draftFallback?.fallback && + (!composerSelectedModelSelection || + (composerSelectedModelSelection.instanceId === fallbackSelection?.instanceId && + composerSelectedModelSelection.model === fallbackSelection?.model)) + ? draftFallback.fallback + : null; + const fallbackWindow = fallback + ? `${fallback.provider.instanceId}:${fallback.resetsAt ?? ""}` + : null; + const fallbackItems: ComposerBannerStackItem[] = + fallback && + fallbackSelection && + defaultSelection && + fallbackWindow !== dismissedFallbackWindow + ? [ + modelFallbackBannerItem({ + fallbackModelName: + providerStatuses + .find((provider) => provider.instanceId === fallbackSelection.instanceId) + ?.models.find((model) => model.slug === fallbackSelection.model)?.name ?? + fallbackSelection.model, + defaultProviderName: fallback.provider.displayName ?? fallback.provider.driver, + defaultModelName: + fallback.provider.models.find((model) => model.slug === defaultSelection.model) + ?.name ?? defaultSelection.model, + resetsAt: fallback.resetsAt, + timestampFormat, + onUseDefault: () => + onProviderModelSelectStable(defaultSelection.instanceId, defaultSelection.model), + onDismiss: () => setDismissedFallbackWindow(fallbackWindow), + }), + ] + : []; const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner]; const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -2547,6 +2629,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...cloud.bannerItems, ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2556,6 +2639,7 @@ export default function ChatView(props: ChatViewProps) { return [ ...cloud.bannerItems, ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2616,6 +2700,11 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_PROVIDER_SKILLS; const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, @@ -3223,6 +3312,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> @@ -3501,7 +3591,7 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={composerBannerItems} + bannerItems={isDraftHeroState ? [] : composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3672,6 +3762,12 @@ export default function ChatView(props: ChatViewProps) { )} + {isDraftHeroState && composerBannerItems.length > 0 ? ( + // Out of flow, like the headline, so a notice never moves the centered composer. +
+ +
+ ) : null}
file.fileKey); + const testFileCount = renderableFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // Hiding is display-only: lazy loading indexes into the full list. A requested file stays visible. + const isFileShown = (file: FileDiffMetadata) => { + const path = resolveFileDiffPath(file); + return !hideTestFiles || path === selectedFilePath || !isTestChangedFile(path); + }; + const shownFileEntries = renderableFileEntries.filter(({ fileDiff }) => isFileShown(fileDiff)); + const shownFileKeys = shownFileEntries.map((file) => file.fileKey); const defaultCollapsedDiffFileKeys = settings.diffFilesCollapsed ? new Set(diffFileKeys) : EMPTY_COLLAPSED_DIFF_FILE_KEYS; @@ -561,7 +575,7 @@ export default function DiffPanel({ count={renderableFiles.length - settledFileCount} /> ) : null; - const codeViewFiles = renderableFileEntries + const codeViewFiles = shownFileEntries .filter( ({ fileDiff }) => !lazySource || !readyFilePaths || readyFilePaths.has(resolveFileDiffPath(fileDiff)), @@ -574,10 +588,12 @@ export default function DiffPanel({ collapsed: collapsedDiffFileKeys.has(fileKey) || fileDiff.cacheKey?.endsWith(":pending") === true, })); - const pendingFiles = renderableFiles.filter((file) => file.cacheKey?.endsWith(":pending")); - const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); + const pendingFiles = renderableFiles.filter( + (file) => file.cacheKey?.endsWith(":pending") && isFileShown(file), + ); + const allDiffFilesCollapsed = areAllDiffFilesCollapsed(shownFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); - const fileTreeEntries = diffFileTreeEntries(renderableFiles); + const fileTreeEntries = diffFileTreeEntries(renderableFiles.filter(isFileShown)); const codeViewReveal = useCodeViewFileReveal(); const revealDiffFile = (filePath: string) => { const index = renderableFileEntries.findIndex( @@ -661,7 +677,7 @@ export default function DiffPanel({ const toggleDiffFileCollapse = () => { setCodeViewRevision((current) => current + 1); - updateCollapsedDiffFiles((current) => toggleAllDiffFiles(diffFileKeys, current)); + updateCollapsedDiffFiles((current) => toggleAllDiffFiles(shownFileKeys, current)); }; const selectTurn = (runId: RunId) => { @@ -991,6 +1007,28 @@ export default function DiffPanel({ {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} + {testFileCount > 0 && ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles + ? `Show ${testFileCount} hidden test ${testFileCount === 1 ? "file" : "files"}` + : "Hide test files"} + + + )} {diffFileKeys.length > 0 && ( void; } @@ -926,6 +928,8 @@ export default function GitActionsControl({ activeThreadRef, draftId, compact = false, + showActions = true, + showChanges = true, onOpenChanges, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); @@ -1476,7 +1480,7 @@ export default function GitActionsControl({ return ( <> - {!isRepo ? ( + {!showActions ? null : !isRepo ? ( )} - {isRepo && !quietEmptyDraft ? ( + {showChanges && isRepo && !quietEmptyDraft ? ( - + {/* Its discovery query should stop when the Git actions row that opens it is hidden. */} + {showActions ? ( + + ) : null} { ); }; + +export const OnePasswordIcon: Icon = (props) => ( + + + +); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index fa9cbf0141c3..88793cce4485 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -26,8 +26,62 @@ describe("surface shortcuts", () => { const actions = [ { shortcut: "B", available: true, label: "Browser" }, { shortcut: "D", available: false, label: "Diff" }, + { shortcut: "T", available: true, label: "Terminal" }, + { shortcut: "M", available: true, label: "Device" }, ] as const; + it("falls back to the physical key when the layout types a non-Latin letter or mark", () => { + // Ukrainian layout: the B key types "ะธ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0438", { code: "KeyB" }))).toBe( + actions[0], + ); + // Greek layout: the B key types "ฮฒ". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u03b2", { code: "KeyB" }))).toBe( + actions[0], + ); + // Thai layout: the B key types the vowel sign "\u0e34", a combining mark. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("\u0e34", { code: "KeyB" }))).toBe( + actions[0], + ); + // Arabic (101) layout: the B key types lam alef "\u0644\u0627", two code points. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0644\u0627", { code: "KeyB" })), + ).toBe(actions[0]); + // Ukrainian layout: the D key types "\u0432", but the Diff surface is unavailable. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u0432", { code: "KeyD" })), + ).toBeNull(); + }); + + it("follows the typed letter on Latin layouts that move it", () => { + // Dvorak: the key at QWERTY B types "x", and "b" sits on QWERTY N. + expect(surfaceShortcutActionForKey(actions, shortcutEvent("x", { code: "KeyB" }))).toBeNull(); + // The same key with Shift or Caps Lock types "X". + expect(surfaceShortcutActionForKey(actions, shortcutEvent("X", { code: "KeyB" }))).toBeNull(); + expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyN" }))).toBe( + actions[0], + ); + }); + + it("keeps accented Latin letters typed on a letter key", () => { + // Bรฉpo: the T key types "รจ". + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("\u00e8", { code: "KeyT" })), + ).toBeNull(); + }); + + it("keeps punctuation typed on a letter key", () => { + // French AZERTY: the M key types ",". + expect(surfaceShortcutActionForKey(actions, shortcutEvent(",", { code: "KeyM" }))).toBeNull(); + }); + + it("keeps a dead key on a letter key", () => { + // A dead key starts an accented letter on a Latin layout. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("Dead", { code: "KeyT" })), + ).toBeNull(); + }); + it("leaves modified, composing, and already-handled key events alone", () => { expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull(); expect( @@ -37,6 +91,21 @@ describe("surface shortcuts", () => { surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })), ).toBeNull(); }); + + it("leaves AltGr text alone unless it is a plain letter", () => { + const getModifierState = (key: string) => key === "AltGraph"; + // German layout: AltGr+M types "\u00b5" and sets only AltGraph on Linux and Windows. + expect( + surfaceShortcutActionForKey( + actions, + shortcutEvent("\u00b5", { code: "KeyM", getModifierState }), + ), + ).toBeNull(); + // Finnish layout: AltGr+B types "b", which still matches, as it does in keybindings.ts. + expect( + surfaceShortcutActionForKey(actions, shortcutEvent("b", { code: "KeyB", getModifierState })), + ).toBe(actions[0]); + }); }); describe("surface shortcut typing contexts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index dd62a684e92e..d74e03ea842a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -92,6 +92,7 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; export interface RightPanelTabsProps { mode: PreviewPanelMode; @@ -238,17 +239,17 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" ->; +> & + Partial>; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); return ( - actions.find( - (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), - ) ?? null + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null ); } diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 5f793b3ca90e..ed3a723652a5 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -14,6 +14,7 @@ const testState = vi.hoisted(() => ({ toast: vi.fn(), clipboard: vi.fn(), sessionAtom: null as unknown, + serverVersions: new Map(), })); vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ @@ -36,6 +37,16 @@ vi.mock(import("~/state/session"), async (original) => { }, }; }); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useServerConfigs: (() => + new Map( + [...testState.serverVersions].map(([id, serverVersion]) => [ + id, + { environment: { serverVersion } }, + ]), + )) as never, +})); vi.mock(import("~/state/use-atom-command"), async (original) => ({ ...(await original()), useAtomCommand: (() => testState.updateServer) as never, @@ -84,6 +95,7 @@ function setup(): Disposable { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.clipboard.mockReset(); + testState.serverVersions.clear(); sessionAtom = Atom.make(AsyncResult.success(currentSession)); testState.sessionAtom = sessionAtom; resetConfirmDialogForTests(); @@ -212,4 +224,17 @@ describe("ServerUpdatesAction", () => { ); expect(button(screen, "Update all")!.disabled).toBe(false); }); + + it("skips a machine whose update would cross Stable and Nightly", async () => { + using _state = setup(); + testState.serverVersions.set("batch-a", "0.0.30-nightly.20261001.1"); + testState.updateServer.mockResolvedValue(success()); + await using screen = await mount(); + await click(screen, "Update all"); + + expect(testState.updateServer).toHaveBeenCalledTimes(1); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 21fc9633736d..a7aac69500d1 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -7,6 +7,7 @@ import { import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -31,6 +32,8 @@ import { isRemoteServerUpdate, useGuestServerUpgrade, } from "~/cloud/guestServerUpdate"; +import { useServerConfigs } from "~/state/entities"; +import { crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -46,6 +49,29 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +const CROSS_TRACK_NOTE = "Switch tracks in About โ†’ Update track"; + +/** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */ +function refusedCrossing( + target: { + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; + readonly channel?: DesktopUpdateChannel | undefined; + }, + serverVersion: string | undefined, +): boolean { + return ( + target.channel === undefined && + target.selfUpdate !== null && + serverVersion !== undefined && + crossesReleaseTrack(serverVersion, target.targetVersion) + ); +} + +function CrossTrackNote() { + return {CROSS_TRACK_NOTE}; +} + function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } @@ -57,6 +83,12 @@ export interface ServerUpdateTarget { readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; readonly targetVersion: string; + /** Set only by a track switch; the host's desktop app moves to this track. */ + readonly channel?: DesktopUpdateChannel; + /** A track switch's checked release migration; the host re-checks its own database. */ + readonly targetDatabaseMigration?: number; + /** The host already runs its track's newest build and only saves the track. */ + readonly trackOnly?: boolean; } type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { @@ -106,7 +138,13 @@ export function useServerUpdate() { } const result = await updateServer({ environmentId, - input: { targetVersion }, + input: { + targetVersion, + ...(target.channel === undefined ? {} : { channel: target.channel }), + ...(target.targetDatabaseMigration === undefined + ? {} + : { targetDatabaseMigration: target.targetDatabaseMigration }), + }, }); pendingUpdateEnvironmentIds.delete(environmentId); if (result._tag === "Failure") { @@ -145,9 +183,14 @@ export function ServerUpdatesAction({ readonly targets: ReadonlyArray; }) { const update = useServerUpdate(); + const serverConfigs = useServerConfigs(); const pending = useRef(false); const [isPending, setIsPending] = useState(false); - const eligible = targets.filter(isRemoteServerUpdate); + const eligible = targets.filter( + (target) => + isRemoteServerUpdate(target) && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), + ); const handleUpdate = async () => { if (pending.current) return; pending.current = true; @@ -221,6 +264,7 @@ export function ServerUpdateAction({ installation, desktopAppUpdate = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", @@ -228,6 +272,7 @@ export function ServerUpdateAction({ appearance = "button", }: ServerUpdateTarget & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const sessionState = useAtomValue(sessionStateAtom); const guestLabel = guestServerUpdateLabel(environmentId, selfUpdate); @@ -288,6 +333,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null && guestLabel === null ? manualServerUpdateCommand(targetVersion, installation) @@ -377,6 +426,9 @@ export function OutdatedServerUpdateAction({ description: `Reconnected on t3@${result.value.targetVersion}.`, }); }; + if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) { + return ; + } return ( +
+ )} ); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c8add80b5811..ae0142f177b6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -243,7 +243,7 @@ import { type ThreadShell, } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { LatestProposedPlanState, PendingApproval, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 9fca07a05466..468236082562 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -2,6 +2,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, isHomeThreadId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -55,6 +56,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -81,6 +83,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -197,6 +200,7 @@ export const ChatHeader = memo(function ChatHeader({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, + onFind: onFindInThread, canShareWithGuest, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index ef07497171ee..0710f2534dad 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -33,27 +33,36 @@ const variantColors: Record = { "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]", }; +/** + * An attached surface tucks its bottom under the card below it. A detached one is a whole card + * of its own, for a notice that sits apart from the composer. + */ function Surface({ variant = "default", + detached = false, className, ...props }: ComponentProps<"div"> & { variant?: ComposerBannerVariant; + detached?: boolean; }) { return (
& { +}: ComponentProps & { density?: "default" | "comfortable" | "spacious"; - variant?: ComposerBannerVariant; width?: "fill" | "content"; }) { return ( diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 086826cde86d..755d20ba8c6a 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -7,7 +7,7 @@ import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; import { observeResize } from "~/lib/observeResize"; /** - * Where a notice sits above the composer. The machine comes first, then what needs you; the + * Where a notice sits in the stack. The machine comes first, then what needs you; the * order is fixed so rows never trade places as notices come and go. */ export type ComposerBannerGroup = "environment" | "attention"; @@ -27,23 +27,33 @@ export interface ComposerBannerStackItem { const GROUPS = ["environment", "attention"] as const; +const FADE_IN = + "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none"; + /** * Notices about sending from this composer, each its own attached card. Consecutive cards join - * edge to edge, and the last joins the composer or the drawer below it. + * edge to edge, and the last joins the composer or the drawer below it. Detached cards stand + * apart instead, for layouts where the composer must not move when a notice appears. */ export function ComposerBannerStack({ items, + detached = false, }: { readonly items: ReadonlyArray; + readonly detached?: boolean; }) { - return GROUPS.flatMap((group) => + const ordered = GROUPS.flatMap((group) => items.filter((item) => (item.group ?? "attention") === group), - ).map((item) => ( - + ); + if (detached) { + return ordered.map((item) => ( +
+ +
+ )); + } + return ordered.map((item) => ( + )); @@ -125,11 +135,18 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { return ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 52800d94a8bc..58ad8e8c0900 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -9,6 +9,7 @@ import { import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests"; import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ ...(await importOriginal()), @@ -73,20 +74,16 @@ function questionProjection(threadId: ThreadId, requestIds = ["first", "second"] } satisfies OrchestrationV2ThreadProjection; } -let requests: ReturnType; - -function PendingRequestsProbe({ - environmentId, - projection, - loading = false, -}: { +interface ProbeProps { environmentId: EnvironmentId; projection: OrchestrationV2ThreadProjection; loading?: boolean; -}) { +} + +function useProbeRequests({ environmentId, projection, loading = false }: ProbeProps) { const promptRef = useRef(""); const composerRef = useRef(null); - const state = usePendingThreadRequests({ + return usePendingThreadRequests({ environmentId, activeThreadId: projection.thread.id, serverProjection: loading ? null : projection, @@ -97,6 +94,12 @@ function PendingRequestsProbe({ composerRef, setThreadError: () => {}, }); +} + +let requests: ReturnType; + +function PendingRequestsProbe(props: ProbeProps) { + const state = useProbeRequests(props); useLayoutEffect(() => { requests = state; }); @@ -138,6 +141,40 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +function QuestionPositionProbe(props: ProbeProps) { + const state = useProbeRequests(props); + return ( + <> + Question {state.activePendingQuestionIndex + 1} + + + + ); +} + +it("stays on an unanswered question when the composer submits", async () => { + await using screen = await render( + , + ); + const submit = screen.getByRole("button", { name: "Submit" }); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 1"); + + await userEvent.click(screen.getByRole("button", { name: "Choose Incremental" })); + await userEvent.click(submit); + await expect.element(screen.getByRole("status")).toHaveTextContent("Question 2"); +}); + it("isolates drafts by environment, thread and request and discards only resolved requests", async () => { using _timers = fakeTimers(); const environmentId = EnvironmentId.make("scope-environment"); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 697a0e0bfd6f..e3e14c4a8400 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -3,10 +3,8 @@ import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; -import { - derivePendingUserInputProgress, - type PendingUserInputDraftAnswer, -} from "../../pendingUserInput"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx index 13614b8c9bd4..213189064ee1 100644 --- a/apps/web/src/components/chat/ComposerSurface.tsx +++ b/apps/web/src/components/chat/ComposerSurface.tsx @@ -41,7 +41,7 @@ function Host({ className, ...props }: ComponentProps<"div">) { "relative z-10 w-full rounded-2xl shadow-composer after:z-1 dark:shadow-none", "group-data-context-row/composer-surface:before:pointer-events-none group-data-context-row/composer-surface:before:absolute group-data-context-row/composer-surface:before:inset-0 group-data-context-row/composer-surface:before:-z-1 group-data-context-row/composer-surface:before:rounded-2xl group-data-context-row/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity) group-data-context-row/composer-surface:before:backdrop-blur-(--glass-blur) group-data-context-row/composer-surface:before:backdrop-saturate-(--glass-saturation)", outlineClasses, - "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", + "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:before:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden", className, )} {...props} diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 294816c140a2..12c11d213cdf 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -1,8 +1,10 @@ import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; import { limitsNotice } from "@t3tools/shared/usageLimits"; import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; @@ -67,6 +69,39 @@ export function usageLimitsBannerItem( }; } +export function modelFallbackBannerItem({ + fallbackModelName, + defaultProviderName, + defaultModelName, + resetsAt, + timestampFormat, + onUseDefault, + onDismiss, +}: { + readonly fallbackModelName: string; + readonly defaultProviderName: string; + readonly defaultModelName: string; + readonly resetsAt: string | null; + readonly timestampFormat: TimestampFormat; + readonly onUseDefault: () => void; + readonly onDismiss: () => void; +}): ComposerBannerStackItem { + return { + id: "fallback-model", + variant: "info", + icon: , + title: `Using ${fallbackModelName}`, + description: `${defaultProviderName} is out of usage${resetsAt ? ` ยท resets ${formatUpcomingTimestamp(resetsAt, timestampFormat)}` : ""}`, + actions: ( + + ), + dismissLabel: "Dismiss fallback model notice", + onDismiss, + }; +} + function UsageLimitsBannerBody({ report, environmentId, diff --git a/apps/web/src/components/chat/DiffStatLabel.test.ts b/apps/web/src/components/chat/DiffStatLabel.test.ts new file mode 100644 index 000000000000..2ea8bcdb8355 --- /dev/null +++ b/apps/web/src/components/chat/DiffStatLabel.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatCompactDiffCount } from "./DiffStatLabel"; + +describe("formatCompactDiffCount", () => { + it("promotes counts when rounding reaches the next suffix", () => { + expect(formatCompactDiffCount(999_499)).toBe("999k"); + expect(formatCompactDiffCount(999_500)).toBe("1m"); + expect(formatCompactDiffCount(999_499_999)).toBe("999m"); + expect(formatCompactDiffCount(999_500_000)).toBe("1b"); + }); +}); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 9e4c0f7cef5e..2b60c35723a6 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -5,13 +5,13 @@ export function hasNonZeroStat(stat: { additions: number; deletions: number }): return stat.additions > 0 || stat.deletions > 0; } -function formatCompactDiffCount(value: number): string { +export function formatCompactDiffCount(value: number): string { if (value < 1000) return String(value); - if (value < 1_000_000) { + if (value < 999_500) { const k = value / 1000; return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`; } - if (value < 1_000_000_000) { + if (value < 999_500_000) { const m = value / 1_000_000; return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`; } diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index aace2fd42634..650d6e398f6a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -26,7 +26,11 @@ import { projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useEnvironments, + usePrimaryEnvironmentId, + useEnvironmentProviders, +} from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; @@ -46,6 +50,8 @@ import { DraftProjectName } from "./DraftProjectName"; import { DraftHeroProjectHeading } from "./DraftHeroProjectHeading"; import { TriageDraftHero } from "./TriageDraftHero"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveProjectDefaultProviderModelSelection } from "../../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; const ADD_PROJECT_VALUE = "add-project"; @@ -68,6 +74,7 @@ export function DraftHeroHeadline({ const projects = useProjects(); const threads = useThreadShells(); const { environments } = useEnvironments(); + const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); @@ -204,13 +211,33 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( + const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings + )?.serverConfig; + const environmentSettings = environmentConfig?.settings; + const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection : project.defaultModelSelection; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + defaultSelection ?? + (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings + ? null + : resolveProjectDefaultProviderModelSelection( + providersByEnvironment.get(project.environmentId) ?? [], + environmentSettings, + project.id, + )), + fallbackSelection: environmentSettings?.fallbackModelSelection ?? null, + providers: providersByEnvironment.get(project.environmentId) ?? [], + ...(environmentSettings ? { settings: environmentSettings } : {}), + projectId: project.id, + now: Date.now(), + }); + const defaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : defaultSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3ee079809f14..8f4b15cc11f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3841,9 +3841,15 @@ it("keeps the working header in place across worktree setup handoff", () => { }); describe("failed turn transcript", () => { - it.each(["provider_error", "usage_limit"] as const)( - "keeps historical %s failures and preceding work visible without disclosures", - (failureClass) => { + it.each([ + { position: "historical", failureClass: "provider_error" }, + { position: "historical", failureClass: "usage_limit" }, + { position: "latest", failureClass: "usage_limit" }, + ] as const)( + "keeps $position $failureClass failures visible with preceding work grouped appropriately", + ({ position, failureClass }) => { + const isLatest = position === "latest"; + const commands = isLatest ? ["pwd", "ls", "false"] : ["pwd"]; const runId = RunId.make("failed-run"); const threadId = ThreadId.make("failed-thread"); const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z"); @@ -3880,23 +3886,24 @@ describe("failed turn transcript", () => { attachments: [], }, }, - { - position: 1, + ...commands.map((input, index): OrchestrationV2ProjectedTurnItem => ({ + position: index + 1, visibility: "local", sourceThreadId: threadId, - sourceItemId: TurnItemId.make("command"), + sourceItemId: TurnItemId.make(index === 0 ? "command" : `command-${index}`), item: { ...base, - id: TurnItemId.make("command"), + id: TurnItemId.make(index === 0 ? "command" : `command-${index}`), + ordinal: index + 1, type: "command_execution", status: "completed", - input: "pwd", + input, output: "", exitCode: 0, }, - }, + })), { - position: 2, + position: commands.length + 1, visibility: "local", sourceThreadId: threadId, sourceItemId: TurnItemId.make("failure"), @@ -3918,20 +3925,24 @@ describe("failed turn transcript", () => { timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items, optimisticMessages: [], - }).map((entry) => ({ - ...entry, - attempt: { - id: RunAttemptId.make("superseded-attempt"), - runId, - attemptOrdinal: 1, - rootNodeId: NodeId.make("superseded-root"), - status: "superseded" as const, - completedAt: null, - }, - })), + }).map((entry) => + isLatest + ? entry + : { + ...entry, + attempt: { + id: RunAttemptId.make("superseded-attempt"), + runId, + attemptOrdinal: 1, + rootNodeId: NodeId.make("superseded-root"), + status: "superseded" as const, + completedAt: null, + }, + }, + ), latestRun: { - runId: RunId.make("newer-run"), - status: "completed", + runId: isLatest ? runId : RunId.make("newer-run"), + status: isLatest ? "failed" : "completed", startedAt: DateTime.formatIso(at), completedAt: DateTime.formatIso(at), }, @@ -3939,16 +3950,19 @@ describe("failed turn transcript", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - expect( - rows.some( - (row) => - row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle", - ), - ).toBe(false); + expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe( + false, + ); const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : [])); - expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]); + expect(rows.find((row) => row.kind === "work-toggle")).toEqual( + isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined, + ); + expect(work.map((entry) => entry.id)).toEqual( + isLatest ? ["failure"] : ["command", "failure"], + ); expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later."); expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z"); + if (isLatest) return; const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"), optimisticMessages: [], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 4ea9e852edfb..c26bcf51a718 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -973,6 +973,24 @@ function failedTimelineRunIds( return failed; } +/** Usage-limit pauses keep their tool groups; any other failure itemizes the run's work. */ +function itemizedFailedRunIds( + entries: ReadonlyArray, + failedRunIds: ReadonlySet, +) { + const usageLimited = new Set(); + const otherFailures = new Set(); + for (const entry of entries) { + const item = timelineEntryFailedItem(entry); + if (item?.runId) { + (item.failure.class === "usage_limit" ? usageLimited : otherFailures).add(item.runId); + } + } + return new Set( + [...failedRunIds].filter((runId) => !usageLimited.has(runId) || otherFailures.has(runId)), + ); +} + function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; @@ -1410,6 +1428,7 @@ export function deriveMessagesTimelineRows(input: { const nextRows: MessagesTimelineRow[] = []; const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); + const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries); const completeSupersededReplyIds = completeSupersededReplyEntryIds(timelineEntries); const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds( @@ -1436,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyEntryIds: completeSupersededReplyIds, }); + const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1681,14 +1701,14 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { - const deferredWorkItemIds = timelineEntries.flatMap((entry) => - turnFold.entryIds.has(entry.id) && - entry.kind === "work" && - entry.entry.projectedItem?.item.deferredWork && - !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + const deferredWorkItemIds = [...turnFold.entryIds].flatMap((entryId) => { + const entry = timelineEntryById.get(entryId); + return entry?.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) ? [String(entry.entry.projectedItem.sourceItemId)] - : [], - ); + : []; + }); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, @@ -1771,7 +1791,7 @@ export function deriveMessagesTimelineRows(input: { if (timelineEntry.kind === "work") { if ( isPendingApproval(timelineEntry.entry) || - (timelineEntry.entry.runId != null && failedRunIds.has(timelineEntry.entry.runId)) || + (timelineEntry.entry.runId != null && itemizedRunIds.has(timelineEntry.entry.runId)) || (timelineEntry.entry.itemType === "error" && timelineEntry.entry.toolLifecycleStatus === "failed") || timelineEntry.entry.tone === "error" || @@ -1797,6 +1817,8 @@ export function deriveMessagesTimelineRows(input: { !nextEntry || nextEntry.kind !== "work" || nextEntry.entry.sourceActivityKind === "context-compaction" || + (nextEntry.entry.itemType === "error" && + nextEntry.entry.toolLifecycleStatus === "failed") || nextEntry.entry.tone === "error" || nextEntry.entry.sourceActivityKind === "runtime.error" || nextEntry.entry.itemType === "system_notice" || diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index b7430716f96f..c5a8cdf64c18 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -41,6 +41,7 @@ import { } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, @@ -377,6 +378,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const [searchQuery, setSearchQuery] = useState(""); const modelListRef = useRef(null); const pickerContentRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes this picker's mount to global keydown handlers + useLayoutEffect(() => registerModelPickerContent(), []); const highlightedModelKeyRef = useRef(null); const { favorites } = useEnvironmentModelPreferences(props.environmentId); const activeEntry = props.instanceEntries.find( diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index b34867b2668f..720ce4d6c443 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -189,6 +189,19 @@ describe("host editor access", () => { }); }); +describe("remote editor links", () => { + it("opens the link from the fallback when the picker is not mounted", async () => { + await using fixture = await setup(); + const { screen, pressOpenFavorite, FavoriteEditorFallback } = fixture; + state.remote = { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }; + await act(async () => screen.rerender()); + + expect((await pressOpenFavorite()).defaultPrevented).toBe(true); + expect(state.openUrl).toHaveBeenCalledWith(expect.stringContaining("ssh-remote+sol")); + expect(state.run).not.toHaveBeenCalled(); + }); +}); + describe("open in editor announcement", () => { it("speaks only after the shortcut opened the editor, from the picker or the fallback", async () => { await using fixture = await setup(); diff --git a/apps/web/src/components/chat/OpenInPickerShortcut.ts b/apps/web/src/components/chat/OpenInPickerShortcut.ts index 8c8249c67b1a..833c3f378498 100644 --- a/apps/web/src/components/chat/OpenInPickerShortcut.ts +++ b/apps/web/src/components/chat/OpenInPickerShortcut.ts @@ -1,11 +1,16 @@ -import type { EditorId, EnvironmentId, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + type EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { useEffect } from "react"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; import { isOpenFavoriteEditorShortcut } from "../../keybindings"; import { announce } from "../../lib/announce"; -import { useRemoteOpenState } from "../../remoteOpen"; +import { openRemoteEditorUrl, useRemoteCapableEditors, useRemoteOpenState } from "../../remoteOpen"; import { registerCommands } from "../../shortcuts/dispatcher"; import { shellEnvironment } from "../../state/shell"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -49,18 +54,39 @@ export function useOpenFavoriteEditorShortcut({ openInCwd: string | null; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor] = usePreferredEditor(availableEditors); const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + // Matches the picker: remote editors run on the viewing machine. + const [preferredEditor] = usePreferredEditor( + remote.mode === "local-exec" ? availableEditors : remoteCapableEditors, + ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled locally + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a window keydown listener for the open-in-editor shortcut while enabled useEffect(() => { - if (!enabled || remote.mode !== "local-exec") return; + if (!enabled || remote.mode === "remote-unavailable") return; const handler = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || !isOpenFavoriteEditorShortcut(event, keybindings)) return; + if ( + event.repeat || + event.defaultPrevented || + !isOpenFavoriteEditorShortcut(event, keybindings) + ) + return; if (!openInCwd || !preferredEditor) return; event.preventDefault(); const label = editorLabelForPlatform(preferredEditor, navigator.platform); + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor: preferredEditor, + ...remote.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + void openRemoteEditorUrl(url).then((opened) => { + if (opened) announce(`Opened in ${label}`); + }); + return; + } void openInEditorMutation({ environmentId, input: { @@ -80,6 +106,6 @@ export function useOpenFavoriteEditorShortcut({ openInCwd, openInEditorMutation, preferredEditor, - remote.mode, + remote, ]); } diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 20ecde327227..ca2497bd20ee 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -153,12 +153,6 @@ export function ThreadAutomationsPanel(props: { } > - {tasksQuery.error !== null ? ( -

- Could not load automations: {tasksQuery.error} -

- ) : null} -
    {boundTasks.map((task) => (
  • ))}
+ {/* Kept last: the details card treats a section whose last child is empty as empty. */} + {tasksQuery.error !== null ? ( +

+ Could not load automations: {tasksQuery.error} +

+ ) : null} ); } diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index 37110875ed0e..a8728d7879dd 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -42,10 +42,13 @@ function Card({ threadRef, inline, fixture, + rows = 1, }: { threadRef: ScopedThreadRef; inline: boolean; fixture: ReturnType; + /** Rows of 100px shown at full density. Compact always takes 50px. */ + rows?: number; }) { return ( // The web root renders in StrictMode, which re-runs ref callbacks on every mount. @@ -63,8 +66,17 @@ function Card({ detailsCardPlacement: inline ? placement : null, }} > - - {() =>

Details for {threadRef.threadId}

} + + {(density) => ( +

+ Details for {threadRef.threadId} +

+ )}
@@ -136,3 +148,13 @@ it("reopens after a normal close", async () => { await setOpen(true); await expect.element(page.getByText("Details for thread-b")).toBeVisible(); }); + +it("returns to full density when hidden items make the content fit", async () => { + using fixture = setup(); + await using screen = await render(); + const card = page.getByRole("region", { name: "Thread details" }); + await expect.element(card).toHaveAttribute("data-density", "compact"); + + await screen.rerender(); + await expect.element(card).toHaveAttribute("data-density", "full"); +}); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 4add56b80eb0..a5df136f1461 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -1,5 +1,11 @@ import { workspaceAreaProps } from "../../lib/workspaceFocus"; -import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { + useLayoutEffect, + useState, + type MouseEventHandler, + type ReactNode, + type RefObject, +} from "react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; @@ -17,11 +23,16 @@ export function ThreadDetailsCard({ threadRef, anchor, handle, + contentKey, + onContextMenu, children, }: { threadRef: ScopedThreadRef; anchor: RefObject; handle: ReturnType; + /** Changes when the set of rendered items changes, so cached heights are measured again. */ + contentKey: string; + onContextMenu?: MouseEventHandler; children: (density: "full" | "compact" | "essential") => ReactNode; }) { const detailsArea = workspaceAreaProps("details", 3, "Thread details"); @@ -36,7 +47,7 @@ export function ThreadDetailsCard({ const popoverOpen = useRightPanelStore((state) => selectThreadPanelOpen(state.threadPanelVisibilityByThreadKey, threadRef, "popover"), ); - const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}`; + const measurementKey = `${threadRef.environmentId}:${threadRef.threadId}:${preferredPlacement?.width ?? "popup"}:${contentKey}`; const [measurements, setMeasurements] = useState({ key: measurementKey, heights: { full: 0, compact: 0 }, @@ -109,6 +120,7 @@ export function ThreadDetailsCard({ "max-h-[min(calc(100dvh-6.5rem),calc(var(--available-height,100dvh)-1rem))]", )} style={placement ? { maxHeight: height } : undefined} + onContextMenu={onContextMenu} data-thread-details-card > diff --git a/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx new file mode 100644 index 000000000000..d84c13809eaf --- /dev/null +++ b/apps/web/src/components/chat/ThreadDetailsItemsMenu.tsx @@ -0,0 +1,45 @@ +import { + getClientSettings, + updateClientSettings, + useClientSettings, +} from "../../hooks/useSettings"; +import { MenuCheckboxItem, MenuGroup, MenuGroupLabel, MenuItem, MenuSeparator } from "../ui/menu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + setThreadDetailsItem, + THREAD_DETAILS_ITEMS, +} from "./threadDetailsItems"; + +/** Menu body that shows and hides thread details items. The card's context menu and Settings share it. */ +export function ThreadDetailsItemsMenuItems() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + return ( + <> + + Show in thread details + {THREAD_DETAILS_ITEMS.map((item) => ( + + updateClientSettings({ + threadDetailsItems: setThreadDetailsItem(getClientSettings(), item, checked), + }) + } + > + {item.label} + + ))} + + + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + > + Reset to default + + + ); +} diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 0dfdc1121a5b..1b51abeb018f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -17,13 +17,25 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { memo, type ComponentProps } from "react"; +import { memo, useState, type ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; +import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage"; +import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; +import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; +import { useClientSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { SlidersHorizontalIcon } from "lucide-react"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "./threadDetailsPanelStyles"; +import { Menu, MenuPopup } from "../ui/menu"; const GitActionsControl = memo(BaseGitActionsControl); @@ -69,12 +81,15 @@ interface ThreadDetailsPanelProps extends Pick< input: NewProjectScriptInput, ) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; + usage: ThreadDetailsUsageInput; } export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { + const visible = useClientSettings(resolveThreadDetailsVisibility); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); const fileScripts = useT3ProjectFileScripts( props.environmentId, - props.activeProjectScripts ? props.gitCwd : null, + props.activeProjectScripts && visible.scripts ? props.gitCwd : null, ); const activeThreadRef = { environmentId: props.environmentId, threadId: props.threadId }; const branchToolbarProps = { @@ -108,98 +123,182 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { threadRef={{ environmentId: props.environmentId, threadId: props.threadId }} anchor={props.anchor} handle={props.handle} + contentKey={THREAD_DETAILS_ITEMS.filter((item) => visible[item.id]) + .map((item) => item.id) + .join(",")} + onContextMenu={(event) => { + // Popups the card opens bubble here through React; they, fields, links, and selected text + // keep their own menus. + const target = event.target; + const selection = window.getSelection(); + if ( + !(target instanceof Element) || + !event.currentTarget.contains(target) || + target.closest("input, textarea, [contenteditable], a[href]") || + (selection && + !selection.isCollapsed && + (event.currentTarget.contains(selection.anchorNode) || + event.currentTarget.contains(selection.focusNode))) + ) + return; + event.preventDefault(); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }} > - {(density) => ( - <> - -
- {density === "full" ? ( - + {(density) => { + // Section flags follow the rows that will render, so no empty section steals the divider rule. + const show = { + environment: density === "full" && visible.environment, + openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, + scripts: visible.scripts && props.activeProjectScripts !== undefined, + branch: props.gitCwd !== null && props.isGitRepo && visible.branch, + git: + props.gitCwd !== null && + props.activeProjectName !== undefined && + (visible.gitActions || (visible.changes && props.isGitRepo)), + }; + return ( + <> +
+ {show.environment || show.openIn || show.scripts ? ( + +
+ {show.environment ? ( + + ) : null} + + {show.openIn ? ( + + ) : null} + + {show.scripts && props.activeProjectScripts ? ( + + ) : null} +
+
+ ) : null} + + {props.gitCwd && (show.branch || show.git) ? ( + +
+ {show.branch ? ( + + ) : null} + {show.git ? ( + + ) : null} +
+
+ ) : null} + + {density === "full" && !props.draftId && visible.servers ? ( + ) : null} - {density !== "essential" && props.showOpenInPicker ? ( - ) : null} - {props.activeProjectScripts ? ( - ) : null} -
- - - {props.gitCwd ? ( - -
- {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - - ) : null} -
-
- ) : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {visible.usage ? : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */} +
+ { + const rect = event.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: rect.left, y: rect.bottom }); + }} + > + + + Choose what to show + + +
+
- {density === "full" && !props.draftId ? ( - - ) : null} - - )} + !open && setMenuPosition(null)} + > + + new DOMRect(menuPosition.x, menuPosition.y, 0, 0), + } + : undefined + } + align="start" + side="bottom" + > + + + + + ); + }}
); } diff --git a/apps/web/src/components/chat/ThreadDetailsSection.tsx b/apps/web/src/components/chat/ThreadDetailsSection.tsx index 00aacd5a7b15..de5ce6c63825 100644 --- a/apps/web/src/components/chat/ThreadDetailsSection.tsx +++ b/apps/web/src/components/chat/ThreadDetailsSection.tsx @@ -22,7 +22,12 @@ export function ThreadDetailsSection({ {...props} aria-labelledby={showHeading ? headingId : undefined} aria-label={showHeading ? undefined : title} - className={cn("px-2 pt-2 pb-2.5", separated && "border-t border-border/65")} + // A row can still render nothing (a fresh draft quiets Git actions), so an empty section + // collapses, and a divider only follows a section that has content. + className={cn( + "px-2 pt-2 pb-2.5 has-[>div:last-child:empty]:hidden", + separated && "border-border/65 [section:not(:has(>div:last-child:empty))~&]:border-t", + )} >
= { + ahead: "Ahead of pace", + on: "On pace", + under: "Under pace", +}; + +export interface ThreadDetailsUsageInput { + readonly providerInstanceId: ProviderInstanceId | null; + readonly providers: readonly ServerProvider[]; + readonly sources: UsageLimitSourceSnapshots; + readonly contextWindow: ContextWindowSnapshot | null; +} + +/** + * The selected provider's limit windows and the thread's context window. + * Every row reads as room left, so a longer bar always means more headroom. + */ +export function ThreadDetailsUsage({ + providerInstanceId, + providers, + sources, + contextWindow, +}: ThreadDetailsUsageInput) { + const nowMinute = useNowMinute(); + const now = Date.parse(`${nowMinute}:00Z`); + const report = + providerInstanceId !== null + ? collectProviderUsageLimits(providerInstanceId, providers, sources, now) + : null; + // Only this instance's data: the report keeps one instance per login, and any other entry may be + // a different login or workspace, so fall back to the instance's own snapshot. + const limits = + report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? + providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; + const notice = limits ? limitsNotice(limits) : null; + if (!limits && !contextWindow) return null; + + return ( + +
+ {notice ? ( + {notice}} + /> + ) : null} + {limits && !notice + ? limits.windows.map((window) => ) + : null} + {contextWindow ? : null} +
+
+ ); +} + +function LimitRow({ + window, + now, +}: { + readonly window: ServerProviderUsageWindow; + readonly now: number; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const left = remainingPercent(window); + const pace = paceOf(window, now); + const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN; + return ( + + + {window.label}: {left}% left + + {window.resetsAt && resetsAt > now ? ( + + Resets in {formatDuration(resetsAt - now)} ยท{" "} + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + + ) : null} + {pace ? {PACE_LABEL[pace]} : null} + + } + /> + ); +} + +function ContextRow({ + usage, + spaced, +}: { + readonly usage: ContextWindowSnapshot; + readonly spaced: boolean; +}) { + const maxTokens = usage.maxTokens ?? null; + const left = + maxTokens !== null && usage.usedPercentage !== null + ? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage))) + : null; + const used = formatContextWindowTokens(usage.usedTokens); + return ( + + {left !== null ? Context: {left}% left : null} + + {maxTokens !== null + ? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used` + : `${used} tokens used`} + + + } + value={left === null ? used : undefined} + /> + ); +} + +/** One label, hairline, and value row; the whole row opens the details tooltip. */ +function UsageRow({ + label, + left, + tooltip, + value, + muted = false, + className, +}: { + readonly label: string; + readonly left: number | null; + readonly tooltip: ReactNode; + /** Replaces the percentage when there is none. */ + readonly value?: string | undefined; + readonly muted?: boolean; + readonly className?: string | undefined; +}) { + const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null; + return ( + + + } + > + {label} + {left !== null ? ( + + {left > 0 ? ( + + ) : null} + + ) : null} + + {value ?? `${left}%`} + + + {/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */} + +
{tooltip}
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index 2e00e47ee7da..f1df96dd9213 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -3,7 +3,7 @@ import type { RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; -import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { ChevronDownIcon, SquareIcon } from "lucide-react"; import { useState, type ReactNode } from "react"; import { AgentElapsed } from "./AgentElapsed"; @@ -14,6 +14,7 @@ import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Spinner } from "../ui/spinner"; import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, @@ -158,7 +159,7 @@ export function ThreadLineageWorkflowRow({ } > {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index e107bbc4039c..69403392b5ce 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,7 +49,6 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, - LoaderCircleIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, @@ -76,6 +75,7 @@ import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; +import { Spinner } from "../ui/spinner"; import { cn } from "~/lib/utils"; import { THREAD_DETAILS_PANEL_CHEVRON_CLASS, @@ -802,7 +802,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -841,7 +841,7 @@ export function ThreadRelationshipsPanel(props: { } > {stoppingId === threadId ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 89b52ae5c5cb..228eb6f79b9c 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -9,6 +9,7 @@ import { Button } from "../ui/button"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { HtmlRenderFrame } from "./HtmlRenderFrame"; import { McpAppFrame } from "./McpAppFrame"; +import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; @@ -347,6 +348,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 48b2d8cfb5ec..03a273799860 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -36,7 +36,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; @@ -756,7 +756,7 @@ function RevertUserMessageButton({ /> } > - + Edit from here diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f02780bed3af..79a29d293dc4 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -616,9 +616,12 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : null; // Workflow levels such as Ultracode always show their name; the setting names every level. + // Traits the bars cannot show, such as an agent mode, appear as words in the composer. const effortWord = effortGlyph ? (effortGlyph.special ?? (effortDisplay === "label" ? effortGlyph.label : null)) - : null; + : isComposerOwned + ? triggerLabel + : null; return ( ) : null} + ) : effortWord !== null ? ( + <> + {effortWord} +
{showExplorer ? ( - + ) : null}
diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 22cb582493df..3bc371b5c1ab 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileBreadcrumbChildren, fileBreadcrumbs, retainedBreadcrumbTrail } from "./filePath"; +import { + breadcrumbPathOf, + fileBreadcrumbChildren, + fileBreadcrumbs, + isListableHostFolder, + retainedBreadcrumbTrail, +} from "./filePath"; describe("fileBreadcrumbs", () => { it("starts host paths outside the workspace at the filesystem root", () => { @@ -20,6 +26,26 @@ describe("fileBreadcrumbs", () => { "\\\\server\\share\\report.md", ]); }); + + it("builds home-relative breadcrumbs with navigable folders", () => { + expect(fileBreadcrumbs("t3code", "~/.claude/CLAUDE.md")).toEqual([ + { label: "~", path: "~/", kind: "directory" }, + { label: ".claude", path: "~/.claude", kind: "directory" }, + { label: "CLAUDE.md", path: "~/.claude/CLAUDE.md", kind: "file" }, + ]); + expect(isListableHostFolder("~/")).toBe(true); + expect(isListableHostFolder("~/.claude")).toBe(true); + }); + + it("keeps a project folder named ~ inside the project", () => { + expect(fileBreadcrumbs("t3code", "~").map((crumb) => crumb.path)).toEqual(["", "~"]); + }); +}); + +describe("breadcrumbPathOf", () => { + it("keeps the home root listable", () => { + expect(breadcrumbPathOf("~/")).toBe("~/"); + }); }); describe("fileBreadcrumbChildren", () => { diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 76e10839bb2c..f9f3c9a5ac40 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -23,7 +23,8 @@ export function fileBreadcrumbs(projectName: string, relativePath: string): File return { label: part, // A bare drive (`C:`) means the drive's current directory; its root is `C:\`. - path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : path, + // A bare `~` is not a host path; the home folder is `~/`. + path: /^[A-Za-z]:$/.test(path) ? `${path}\\` : hostPath && path === "~" ? "~/" : path, kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), }; }), diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx index dd644ccf0c82..851daaebe5ea 100644 --- a/apps/web/src/components/preview/RightPanelResizeHandle.tsx +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -1,16 +1,23 @@ +import type { ComponentProps } from "react"; import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; -interface Props { +interface Props extends Pick< + ComponentProps<"div">, + "tabIndex" | "onKeyDown" | "aria-valuenow" | "aria-valuemin" | "aria-valuemax" +> { handlers: ResizableWidthHandlers; className?: string; + label?: string; } -export function RightPanelResizeHandle({ handlers, className }: Props) { +export function RightPanelResizeHandle({ handlers, className, label, ...separatorProps }: Props) { return (
); diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 26ed49fba0fa..50b5ae47c449 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -366,8 +366,8 @@ function DeviceMiniPlayer({ const device = deviceState.devices.find( (entry) => entry.hostId === source.hostId && entry.id === source.deviceId, ); - const hostLabel = - deviceState.hosts.find((host) => host.id === source.hostId)?.label ?? "Device host"; + const host = deviceState.hosts.find((entry) => entry.id === source.hostId); + const hostLabel = host?.label ?? "Device host"; const cornerRadius = (player: PreviewMiniPlayerSize) => resolveDeviceMiniPlayerCornerRadius(source.platform, player); @@ -404,6 +404,7 @@ function DeviceMiniPlayer({ deviceDescription={`${hostLabel} ยท ${device?.version ?? source.platform}`} visible viewId={viewId} + serveSimOnly={host?.kind === "simlock"} /> )} diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx new file mode 100644 index 000000000000..521d9c47f316 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.browser.test.tsx @@ -0,0 +1,212 @@ +import "~/index.css"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { Cause } from "effect"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +const mocks = vi.hoisted(() => ({ + update: + vi.fn<(input: unknown) => Promise<{ _tag: "Success" } | { _tag: "Failure"; cause: unknown }>>(), + notify: vi.fn(), +})); +vi.mock( + import("~/state/pullRequests"), + () => ({ pullRequestEnvironment: { update: {} } }) as never, +); +vi.mock( + import("~/state/use-atom-command"), + () => ({ useAtomCommand: () => mocks.update }) as never, +); + +vi.mock(import("../ui/toast"), () => ({ toastManager: { add: mocks.notify } }) as never); +vi.mock(import("~/state/server"), async () => { + const { Atom } = await import("effect/reactivity"); + return { primaryServerKeybindingsAtom: Atom.make([]) } as never; +}); +vi.mock(import("./PullRequestThreadLinks"), () => ({ PullRequestThreadLinks: () => null })); +vi.mock( + import("./PullRequestDetailActions"), + () => ({ ActOnEnvironmentPicker: () => null }) as never, +); + +import { render } from "../../../test/render"; +import { PullRequestActionsMenu } from "./PullRequestActionsMenu"; + +const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 7 }; +const environmentId = EnvironmentId.make("environment"); + +type Detail = Parameters[0]["detail"]; + +const openDetail: Detail = { + url: "https://github.com/owner/repo/pull/7", + state: "open", + isDraft: false, + headBranch: "feature", + maintainerCanModify: true, + observedAt: 1, + capabilities: { + diff: true, + comment: true, + actions: [], + mergeMethods: [], + search: true, + review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, + }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + maintainerEdits: true, + }, +}; + +function setup() { + mocks.update.mockReset().mockResolvedValue({ _tag: "Success" }); + mocks.notify.mockReset(); + return (detail: Detail = openDetail) => ( + {}} + refreshing={false} + refreshFromHost={async () => {}} + handoff={null} + attachesToComposer={false} + askAboutPullRequest={() => {}} + explainPullRequest={() => {}} + canFixFindings={false} + startFixFindings={() => {}} + handoffLabels={{ fixFindings: "Fix review findings" }} + canWriteGuide={false} + guideRunning={false} + guideWritten={false} + writeGuide={() => {}} + pickableEnvironments={[]} + actingEnvironmentId={environmentId} + onActingEnvironmentChange={() => {}} + can={() => false} + autoMergeArmed={false} + showsDraftToggle={false} + showsMergeNow={false} + showsAutoMerge={false} + showsMergeMethods={false} + allowedMergeMethods={[]} + selectedMergeMethod="squash" + onSelectMergeMethod={() => {}} + showsDeleteBranch={false} + environmentConnected={true} + canWriteSourceControl={true} + onMaintainerEditsSaved={() => {}} + confirmDeleteBranch={async () => {}} + pendingAction={null} + perform={async () => true} + openOnHostText="Open on GitHub" + copyReference={() => {}} + /> + ); +} + +it("saves maintainer edits, locks while saving, and restores the saved value when a save fails", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + await expect.element(checkbox).toBeChecked(); + await checkbox.click(); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { ...reference, maintainerCanModify: false }, + }); + + let settle!: (value: { _tag: "Failure"; cause: unknown }) => void; + mocks.update.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + await checkbox.click(); + await expect.element(checkbox).toBeChecked(); + await expect.element(checkbox).toBeDisabled(); + await expect.element(page.getByRole("status")).toHaveTextContent("Savingโ€ฆ"); + settle({ + _tag: "Failure", + cause: Cause.fail(new Error("Resource not accessible by integration")), + }); + await expect.element(checkbox).not.toBeChecked(); + expect(mocks.notify).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + type: "error", + title: "Could not save maintainer access", + description: "Resource not accessible by integration", + }), + ); +}); + +it("keeps a saved choice through a stale refresh, then follows the host", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + let settle!: (value: { _tag: "Success" }) => void; + mocks.update.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }), + ); + await checkbox.click(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + settle({ _tag: "Success" }); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, maintainerCanModify: false, observedAt: 3 })); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, maintainerCanModify: true, observedAt: 4 })); + await expect.element(checkbox).toBeChecked(); +}); + +it("follows the host's first reading after a save, even when it matches the old value", async () => { + const control = setup(); + await using screen = await render(control()); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + const checkbox = page.getByRole("menuitemcheckbox", { name: "Allow edits from maintainers" }); + await checkbox.click(); + await expect.element(page.getByRole("status")).not.toBeInTheDocument(); + await expect.element(checkbox).not.toBeChecked(); + await screen.rerender(control({ ...openDetail, observedAt: 2 })); + await expect.element(checkbox).toBeChecked(); +}); + +it.each<[string, Detail]>([ + ["closed", { ...openDetail, state: "closed" }], + [ + "not authored by the viewer", + { + ...openDetail, + viewerPermissions: { ...openDetail.viewerPermissions, maintainerEdits: false }, + }, + ], + [ + "on a host without maintainer edits", + { + ...openDetail, + capabilities: { ...openDetail.capabilities, edit: { changeRequest: true, comment: true } }, + }, + ], +])("hides maintainer edits on a pull request that is %s", async (_, detail) => { + const control = setup(); + await using screen = await render(control(detail)); + await screen.getByRole("button", { name: "More pull request actions" }).click(); + await expect.element(page.getByRole("menuitem", { name: "Open on GitHub" })).toBeVisible(); + expect(document.querySelector('[role="menuitemcheckbox"]')).toBeNull(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index c920bbd3e19f..df402185ce35 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -1,5 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; +import { + canEditPullRequestMaintainerEdits, + maintainerEditsChoiceInForce, + type MaintainerEditsChoice, +} from "@t3tools/client-runtime/pull-request-editing"; import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestAction, @@ -20,13 +26,17 @@ import { SparklesIcon, Trash2Icon, } from "lucide-react"; +import { useId, useState } from "react"; import { shortcutLabelForCommand } from "~/keybindings"; import { readLocalApi } from "~/localApi"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; import { Button } from "../ui/button"; import { Menu, + MenuCheckboxItem, MenuItem, MenuPopup, MenuRadioGroup, @@ -36,11 +46,13 @@ import { MenuTrigger, } from "../ui/menu"; import { RefreshIcon } from "../ui/refresh-icon"; +import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ActOnEnvironmentPicker, type PerformPullRequestAction } from "./PullRequestDetailActions"; import { PULL_REQUEST_MERGE_METHOD_LABELS, pullRequestActionMenuHasGroup, + readableFailure, } from "./pullRequestDetail.logic"; import { PullRequestGlyph } from "./pullRequestIcons"; import type { PickableEnvironment } from "./pullRequestProjectAssignment.logic"; @@ -83,6 +95,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod, showsDeleteBranch, environmentConnected, + canWriteSourceControl, + onMaintainerEditsSaved, confirmDeleteBranch, pendingAction, perform, @@ -92,7 +106,17 @@ export function PullRequestActionsMenu({ environmentId: EnvironmentId; reference: PullRequestRef; pullRequestKey: string; - detail: Pick; + detail: Pick< + PullRequestDetail, + | "url" + | "state" + | "isDraft" + | "headBranch" + | "capabilities" + | "viewerPermissions" + | "maintainerCanModify" + | "observedAt" + >; terminology: ChangeRequestTerminology; linkThreadRef: ScopedThreadRef | null; onThreadPickerOpenChange: (open: boolean) => void; @@ -123,6 +147,8 @@ export function PullRequestActionsMenu({ onSelectMergeMethod: (method: PullRequestMergeMethod) => void; showsDeleteBranch: boolean; environmentConnected: boolean; + canWriteSourceControl: boolean; + onMaintainerEditsSaved: () => void; confirmDeleteBranch: () => Promise; pendingAction: PullRequestAction | null; perform: PerformPullRequestAction; @@ -132,6 +158,47 @@ export function PullRequestActionsMenu({ const keybindings = useAtomValue(primaryServerKeybindingsAtom); const inlineConfirm = useInlineActionConfirm(pullRequestKey); const actionPending = pendingAction !== null; + const maintainerEditsHintId = useId(); + const updatePullRequest = useAtomCommand(pullRequestEnvironment.update, { + reportFailure: false, + }); + const [maintainerEditsChoice, setMaintainerEditsChoice] = useState( + null, + ); + const [maintainerEditsSaving, setMaintainerEditsSaving] = useState(false); + const maintainerEditsChoiceKept = maintainerEditsChoiceInForce( + maintainerEditsChoice, + pullRequestKey, + detail.observedAt, + maintainerEditsSaving, + ); + if (maintainerEditsChoiceKept !== maintainerEditsChoice) { + setMaintainerEditsChoice(maintainerEditsChoiceKept); + } + const maintainerCanModify = maintainerEditsChoiceKept?.value ?? detail.maintainerCanModify; + const canSaveMaintainerEdits = + canWriteSourceControl && environmentConnected && !maintainerEditsSaving; + const saveMaintainerEdits = async (value: boolean) => { + if (!canSaveMaintainerEdits) return; + const previousChoice = maintainerEditsChoice; + setMaintainerEditsChoice({ key: pullRequestKey, observedAt: detail.observedAt, value }); + setMaintainerEditsSaving(true); + const result = await updatePullRequest({ + environmentId, + input: { ...reference, maintainerCanModify: value }, + }); + setMaintainerEditsSaving(false); + if (result._tag === "Failure") { + setMaintainerEditsChoice(previousChoice); + toastManager.add({ + type: "error", + title: "Could not save maintainer access", + description: readableFailure(squashAtomCommandFailure(result), "Try again."), + }); + return; + } + onMaintainerEditsSaved(); + }; return (
@@ -218,6 +285,33 @@ export function PullRequestActionsMenu({ /> ) : null} + {canEditPullRequestMaintainerEdits(detail) ? ( + <> + void saveMaintainerEdits(value)} + > + + Allow edits from maintainers + + Maintainers can push to this branch. If your fork has workflows, this can also + give access to its secrets. + + {maintainerEditsSaving ? ( + + Savingโ€ฆ + + ) : null} + + + + + ) : null} {detail.state === "open" ? ( <> {showsDraftToggle ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d1b165c62147..94f4ccda8255 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -19,6 +19,7 @@ import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, + FlaskConicalOffIcon, FolderTreeIcon, InfoIcon, MessageSquareOffIcon, @@ -51,6 +52,7 @@ import { canEditPullRequestComment, canResolvePullRequestThread, } from "@t3tools/client-runtime/pull-request-editing"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, @@ -80,7 +82,8 @@ import { useDiffWorkerPool } from "../DiffWorkerPoolProvider"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; -import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; +import { useHideTestFiles } from "../diffs/useHideTestFiles"; +import { diffFileTreeEntries, orderDiffFilesLikeTree } from "../diffs/diffFileTree.logic"; import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -307,6 +310,7 @@ function PullRequestCodeTab({ false, Schema.Boolean, ); + const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -496,9 +500,22 @@ function PullRequestCodeTab({ ignoreWhitespace, ), ); - const files = parsedSlices.flatMap((parsed) => - parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + const loadedFiles = orderDiffFilesLikeTree( + parsedSlices.flatMap((parsed) => + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], + ), ); + const testFileCount = loadedFiles.filter((file) => + isTestChangedFile(resolveFileDiffPath(file)), + ).length; + // A requested file stays visible so a jump to a hidden test still lands. + const files = hideTestFiles + ? loadedFiles.filter((file) => { + const path = resolveFileDiffPath(file); + return path === revealRequest?.path || !isTestChangedFile(path); + }) + : loadedFiles; + const hiddenTestFileCount = loadedFiles.length - files.length; const filePaths = files.map((file) => resolveFileDiffPath(file)); const omittedFileStats = new Map( loadedSlices.flatMap((slice) => @@ -1071,6 +1088,11 @@ function PullRequestCodeTab({ {files.length} {files.length === 1 ? "file" : "files"} {nextCursor === null ? "" : "+"} + {hiddenTestFileCount > 0 ? ( + + ) : null} {filesViewed.enabled && files.length > 0 ? ( @@ -1283,6 +1305,26 @@ function PullRequestCodeTab({ ) : null} + {testFileCount > 0 ? ( + + setHideTestFiles(Boolean(pressed))} + /> + } + > + + + + {hideTestFiles ? "Show test files" : "Hide test files"} + + + ) : null} {fileKeys.length > 0 ? ( ({ + pages: new Map>>(), +})); +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { + diff: ({ input }: { input: { cursor?: string } }) => state.pages.get(input.cursor ?? null)!, + }, +})); + +import { useGuideDiffFiles } from "./useGuideDiffFiles"; + +function Probe({ wantedPaths }: { wantedPaths: string[] }) { + const diff = useGuideDiffFiles({ + environmentId: EnvironmentId.make("test"), + reference: { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 }, + headSha: "new-head", + wantedPaths, + }); + return ( + + {JSON.stringify({ + loading: diff.loading, + files: [...diff.files].map(([path, file]) => [path, file.additionLines.join("")]), + })} + + ); +} + +function page(path: string, value: string, nextCursor: string | null): PullRequestDiffResult { + return { + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-old\n+${value}\n`, + truncated: false, + nextCursor, + }; +} + +it.each([ + { name: "contains the requested file", path: "wanted.ts", cursor: null }, + { name: "ends before a newly added file", path: "other.ts", cursor: null }, + { name: "has an obsolete next cursor", path: "other.ts", cursor: "old-next" }, +])("waits for a cached page that $name to settle", async ({ path, cursor }) => { + state.pages.clear(); + await using stack = new AsyncDisposableStack(); + const registry = stack.adopt(AtomRegistry.make(), (value) => value.dispose()); + const first = Atom.make>( + AsyncResult.success(page(path, "STALE", cursor), { waiting: true }), + ); + state.pages.set(null, first); + state.pages.set( + "old-next", + Atom.make>(AsyncResult.initial(true)), + ); + await using screen = await render( + + + , + ); + const result = () => JSON.parse(screen.container.querySelector("output")!.textContent!); + expect(result()).toEqual({ loading: true, files: [] }); + + await act(() => registry.set(first, AsyncResult.success(page("wanted.ts", "FRESH", null)))); + expect(result()).toEqual({ loading: false, files: [["wanted.ts", "FRESH"]] }); +}); diff --git a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts index c905488949cf..21765c68d18e 100644 --- a/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.ts @@ -51,7 +51,7 @@ export function useGuideDiffFiles({ ); const data = query.data; - if (data !== null && !current.done && current.folded !== data) { + if (data !== null && !query.isPending && !current.done && current.folded !== data) { const parsed = getRenderablePatch( data.patch, `pull-request-guide:${key}:${current.cursor ?? "first"}`, diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts index ec3319f01084..59d7e0c83ad1 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { findScopedProject } from "./pullRequestList.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; const cups = EnvironmentId.make("env-cups"); const nucbox = EnvironmentId.make("env-nucbox"); diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts index c4e917fa7134..fe22fe5af283 100644 --- a/apps/web/src/components/pullRequest/usePullRequestListView.ts +++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts @@ -36,7 +36,7 @@ import { import type { PullRequestExpectedHost } from "./PullRequestListFilters"; import type { PullRequestListPreferences, PullRequestListSort } from "./pullRequestListPreferences"; import { assignProjectsToEnvironments } from "./pullRequestProjectAssignment.logic"; -import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { pullRequestListEntryId } from "./pullRequestListGroups.logic"; export interface PullRequestsSearch extends PullRequestListPreferences { diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts new file mode 100644 index 000000000000..33192bbaab71 --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts @@ -0,0 +1,61 @@ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; + +describe("adding usage limit sources", () => { + it.each([ + ["http://proxy.test:8318/hub-a", "http://proxy.test:8318/hub-b"], + ["localhost:8318", "localhost:9318"], + ])("retains saved configurations for %s and %s", (firstUrl, secondUrl) => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + const sources = [ + [firstUrl, "First hub", "first-key"], + [secondUrl, "Second hub", "second-key"], + [`${secondUrl}/extra`, "Third hub", "third-key"], + ] as const; + for (const [url, label, managementKey] of sources) { + const id = sourceIdFromUrl(url, settings.usageLimitSources); + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [id]: { kind: "cliproxy", url, label, managementKey, enabled: true }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual( + sources.map(([url, label, managementKey]) => ({ + kind: "cliproxy", + url, + label, + managementKey, + enabled: true, + })), + ); + }); + + it("updates a saved hub when its URL is added again", () => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + for (const managementKey of ["old-key", "new-key"]) { + const url = "http://proxy.test:8318/hub-a"; + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [sourceIdFromUrl(url, settings.usageLimitSources)]: { + kind: "cliproxy", + url, + managementKey, + enabled: true, + }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual([ + { + kind: "cliproxy", + url: "http://proxy.test:8318/hub-a", + managementKey: "new-key", + enabled: true, + }, + ]); + }); +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts new file mode 100644 index 000000000000..67ccabdefefc --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts @@ -0,0 +1,24 @@ +import { type UnifiedSettings, UsageLimitSourceId } from "@t3tools/contracts"; + +export function sourceIdFromUrl( + url: string, + sources: UnifiedSettings["usageLimitSources"], +): UsageLimitSourceId { + const saved = Object.entries(sources).find(([, source]) => source.url === url); + if (saved) return UsageLimitSourceId.make(saved[0]); + let host = url; + try { + host = new URL(url).host; + } catch {} + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + // Settings merge by ID, so adding a different hub must not replace an existing entry. + const base = `cliproxy-${slug || "hub"}`; + let id = base; + for (let suffix = 2; id in sources; suffix += 1) { + id = `${base}-${suffix}`; + } + return UsageLimitSourceId.make(id); +} diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index cc150016d73c..e3abd8b95106 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -13,32 +13,23 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; -function sourceIdFromUrl(url: string): UsageLimitSourceId { - let host = url; - try { - host = new URL(url).host; - } catch {} - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); -} - /** Send the management key once to server secret storage; settings retain only a redaction marker. */ export function AddUsageLimitSourceDialog({ open, onOpenChange, environmentId, environmentLabel, + sources, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly sources: UnifiedSettings["usageLimitSources"]; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); @@ -55,7 +46,7 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); + const id = sourceIdFromUrl(trimmedUrl, sources); updateSettings({ usageLimitSources: { [id]: { diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index f47ee3f52c79..1c5812a2bda0 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -34,6 +34,7 @@ import { searchableSetting } from "./settingsSearch"; import { AdvancedSettings } from "./AdvancedSettings"; import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; import { TypographySection } from "./TypographySettings"; +import { ThreadDetailsItemsRow } from "./ThreadDetailsItemsRow"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", @@ -273,6 +274,7 @@ export function AppearanceSettingsPanel() { } /> + +
{authActive ? ( <> {waitingControl} @@ -1001,6 +1001,16 @@ function ManagedCodexSetup({ Use a different account ) : null} + {allowExistingCli ? ( + + ) : null} )}
diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx deleted file mode 100644 index fb5a1b2517e7..000000000000 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { DeviceToolVersions } from "../device/DeviceToolVersions"; -import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; -import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; -import { AsyncResult } from "effect/reactivity"; -import { AuthSettingsWriteScope } from "@t3tools/contracts"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; -import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; -import { AppleIcon, AndroidIcon } from "../Icons"; -import { Spinner } from "../ui/spinner"; -import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts"; -import { randomUUID } from "../../lib/utils"; -import { useState } from "react"; -import { deviceEnvironment } from "../../state/device"; -import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; -import { serverEnvironment } from "../../state/server"; -import { useAtomCommand } from "../../state/use-atom-command"; -import { Button, InlineButton } from "../ui/button"; -import { - CheckIcon, - ChevronDownIcon, - CircleIcon, - MoreVertical, - PlusIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; -import { SettingsRow } from "./settingsLayout"; - -import { useSettingsScope } from "./SettingsScopeContext"; -import { toastManager } from "../ui/toast"; -import { - groupDeviceHosts, - updateDeviceHosts, - type DeviceHostEnvironment, - type DeviceHostGroup, -} from "./deviceHostsSettings.logic"; -import { FoldedSettingsSection } from "./FoldedSettingsSection"; -import { DeviceHostEditor } from "./DeviceHostEditor"; -import { useHostConnectionChecks } from "./useHostConnectionChecks"; -import { deviceHostConnectionKey } from "./deviceHostConnectionChecks"; -import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis"; -import { DeviceHostDiagnostics } from "../device/DeviceSetup"; - -export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, environments, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const [editing, setEditing] = useState(null); - const [originalHost, setOriginalHost] = useState(null); - const [busy, setBusy] = useState(false); - const targets = environments.map((environment) => ({ - environmentId: environment.environmentId, - label: settingsScopeEnvironmentLabel(environment, environments), - connected: environment.connection.phase === "connected", - })); - const { checks, testConnection } = useHostConnectionChecks(targets); - const hostEnvironments = connectedEnvironments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - labels: settingsScopeEnvironmentLabels(environment, connectedEnvironments), - hosts: environment.serverConfig?.settings.deviceHosts ?? [], - })); - const groupedHosts = groupDeviceHosts(hostEnvironments); - const save = async (host: SshDeviceHostConfig, remove = false, original = host) => { - if (!props.environmentId || projectScope) return; - setBusy(true); - const results = await Promise.allSettled( - environments.map(async (environment) => { - if (environment.connection.phase !== "connected" || !environment.serverConfig) { - throw new Error("Environment disconnected"); - } - if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) { - throw new Error("This connection cannot change device settings."); - } - return update({ - environmentId: environment.environmentId, - input: { - patch: { - deviceHosts: updateDeviceHosts( - environment.serverConfig.settings.deviceHosts, - host, - remove, - original, - ), - }, - }, - }); - }), - ); - setBusy(false); - const failed = environments.filter((_, index) => { - const result = results[index]; - return result?.status !== "fulfilled" || result.value._tag === "Failure"; - }); - if (failed.length === 0) { - setEditing(null); - } else { - toastManager.add({ - type: "error", - title: "Device hosts not saved on all environments", - description: `Could not update ${failed.map((environment) => environment.label).join(", ")}.`, - }); - } - }; - const listActions = { - busy: !canConfigure || projectScope || busy, - checks, - testConnection: async (host: SshDeviceHostConfig) => { - const results = await testConnection(host); - if (!results) return; - const failed = targets.filter((target) => results[target.environmentId]?.status === "failed"); - toastManager.add({ - type: failed.length ? "error" : "success", - title: failed.length - ? `${host.label}: ${failed.length} of ${targets.length} environments failed` - : `${host.label}: connection checks passed`, - description: failed.length - ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.` - : "Connected or already available locally on each selected environment.", - }); - return results; - }, - onEdit: (host: SshDeviceHostConfig) => { - setOriginalHost(host); - setEditing(host); - }, - onRemove: (host: SshDeviceHostConfig) => void save(host, true), - }; - return ( - { - setOriginalHost(null); - setEditing({ kind: "ssh", id: randomUUID(), label: "", target: "" }); - }} - > - Add host - - } - > -
- {!props.environmentId ? ( -

- Connect a selected environment to manage device hosts. -

- ) : ( - <> - {hostEnvironments.length > 1 && groupedHosts === null ? ( -
- {hostEnvironments.map((environment) => ( - - - {environment.labels.primary} - - {environment.labels.secondary ? ( - - {environment.labels.secondary} - - ) : null} - - } - summary={ - summarizeLabels(environment.hosts.map((host) => host.label)) || - "No device hosts" - } - > -
- -
-
- ))} -
- ) : ( - 1} - {...listActions} - /> - )} - {editing ? ( - void save(host, false, originalHost ?? host)} - onClose={() => setEditing(null)} - /> - ) : null} - - )} -
-
- ); -} - -const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => - deviceEnvironment.state({ environmentId, input: {} }), -); - -function DeviceHostList({ - environments, - groups, - compact = false, - busy, - onEdit, - onRemove, - checks, - testConnection, -}: { - environments: ReadonlyArray; - groups?: ReadonlyArray | undefined; - compact?: boolean; - busy: boolean; - onEdit: (host: SshDeviceHostConfig) => void; - onRemove: (host: SshDeviceHostConfig) => void; - checks: ReturnType["checks"]; - testConnection: ReturnType["testConnection"]; -}) { - const { environments: scopeEnvironments } = useSettingsScope(); - const environmentLabels = new Map( - scopeEnvironments.map((environment) => [ - environment.environmentId, - settingsScopeEnvironmentLabels(environment, scopeEnvironments), - ]), - ); - const environmentIds = environments.map(({ environmentId }) => environmentId); - const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(environmentIds))); - const states = new Map( - environmentIds.map( - (environmentId, index) => - [environmentId, Option.getOrUndefined(AsyncResult.value(results[index]!))] as const, - ), - ); - const retry = useAtomCommand(deviceEnvironment.list); - const [retrying, setRetrying] = useState(null); - const retryHost = (environmentId: EnvironmentId, hostId: string, key = hostId) => { - setRetrying(key); - void retry({ environmentId, input: { retryHostId: hostId } }).finally(() => setRetrying(null)); - }; - const rows = - groups ?? - environments.flatMap((environment) => - environment.hosts.map((host) => ({ - host, - members: [{ environmentId: environment.environmentId, label: environment.label, host }], - })), - ); - return ( - <> - {rows.length === 0 ? ( -

No device hosts.

- ) : null} - {rows.map(({ host, members }) => { - const hostChecks = checks[deviceHostConnectionKey(host)]; - const checkedMembers = scopeEnvironments.flatMap((environment) => - hostChecks?.[environment.environmentId] && - !members.some((member) => member.environmentId === environment.environmentId) - ? [{ environmentId: environment.environmentId, label: environment.label, host }] - : [], - ); - const results = [...members, ...checkedMembers].map((member) => { - const state = states.get(member.environmentId); - const status = state?.hostStatuses[member.host.id]; - const check = hostChecks?.[member.environmentId]; - const summary = state?.hosts.find((value) => value.id === member.host.id); - const label = environmentLabels.get(member.environmentId)?.primary ?? member.label; - const address = environmentLabels.get(member.environmentId)?.secondary; - const error = - check?.status === "failed" - ? check.error - : check?.status === "local" - ? undefined - : status?.status === "failed" - ? status.detail - : undefined; - return { - ...member, - label, - address, - name: [label, address].filter(Boolean).join(" ยท "), - state, - status, - check, - summary, - error, - versions: ( - - ), - }; - }); - const first = results[0]; - if (!first) return null; - const { environmentId, status, check, summary, error } = first; - const platforms = - (check?.status === "connected" ? check.platforms : undefined) ?? summary?.platforms ?? []; - const progress = - check?.status === "pending" - ? "Checking connectionโ€ฆ" - : status?.status === "installing" - ? "Installing device supportโ€ฆ" - : status?.status === "starting" - ? "Connectingโ€ฆ" - : null; - const failed = results.filter( - (result) => - result.check?.status === "failed" || - (result.check?.status !== "local" && result.status?.status === "failed"), - ); - const pending = results.some( - (result) => - result.check?.status === "pending" || - result.status?.status === "installing" || - result.status?.status === "starting", - ); - const ready = results.every( - (result) => - result.check?.status === "connected" || - result.check?.status === "local" || - result.status?.status === "ready", - ); - const retryable = results.filter( - (result) => - result.status?.status === "failed" && - result.state?.supportsHostRetry && - result.state.hostStatus !== "disabled", - ); - const canRetry = !compact && retryable.includes(first); - const indicators = compact - ? [ - { - key: "connection", - label: failed.length - ? "Connection failed" - : pending - ? "Checking connections" - : ready - ? "Connected" - : "Connection not checked", - warning: failed.length > 0, - icon: failed.length ? ( - - ) : pending ? ( - - ) : ready ? ( - - ) : ( - - ), - detail: failed.length ? ( -
- {failed.map((result) => ( -

- {result.name}: {result.error || "Connection failed"} -

- ))} -
- ) : pending ? ( - "Checking connectionsโ€ฆ" - ) : ready ? ( - "Connected or available locally on each listed machine" - ) : ( - "Test the connection to check access from each machine" - ), - }, - ] - : platforms - .filter((platform) => platform.available) - .map((platform) => ({ - key: platform.platform, - label: platform.platform === "ios" ? "iOS available" : "Android available", - warning: false, - icon: - platform.platform === "ios" ? ( - // The Apple mark is bottom-heavy; lift it so it does not dip under the label. - - ) : ( - - ), - })); - return ( -
-
-
-

{host.label}

- {indicators.map((indicator) => ( - - - } - > - {indicator.icon} - - - {"detail" in indicator ? indicator.detail : indicator.label} - - - ))} -
-

{host.target}

- {results - .filter( - (result) => - result.status?.status === "ready" && result.status.androidDiscoveryIncomplete, - ) - .map((result) => ( -

- {compact ? `${result.label}: ` : ""} - {result.status?.detail ?? - "The Android device list may be incomplete. Refresh devices to try again."} -

- ))} - {compact ? ( - -
- } - > - - {summarizeLabels(results.map((result) => result.name))} - - - -
- -
- {results.map((result) => ( -
-

{result.label}

- {result.address ? ( -

- {result.address} -

- ) : null} - {result.error ? ( -

- {result.error} -

- ) : null} - {result.state ? ( - - ) : null} - {result.versions} - {result.check?.status === "local" ? ( -

- Already available locally -

- ) : ( - - )} -
- ))} -
-
-
- ) : ( - first.versions - )} - {!compact && check?.status === "local" ? ( -

Already available locally

- ) : null} - {!compact && error ? ( -
-
- Connection failed -

{error}

-
-
- ) : null} - {!compact - ? results.map((result) => - result.state ? ( - - ) : null, - ) - : null} -
- {!compact && progress ? ( - - - {progress} - - ) : null} - - - } - > - - - - onEdit(host)}>Edit - onRemove(host)}> - Remove - - {compact - ? retryable.map((result) => ( - - retryHost(result.environmentId, result.host.id, result.environmentId) - } - > - {retrying === result.environmentId ? "Retrying" : "Retry"} from{" "} - {result.label} - {result.address ? ` (${result.address})` : null} - - )) - : null} - - - -
- ); - })} - - ); -} - -function summarizeLabels(labels: ReadonlyArray) { - return `${labels.slice(0, 2).join(", ")}${labels.length > 2 ? ` +${labels.length - 2}` : ""}`; -} diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx new file mode 100644 index 000000000000..09ccc32fd61b --- /dev/null +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -0,0 +1,802 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Fragment, useState, type ReactNode } from "react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { + AuthSettingsWriteScope, + type DevicePlatform, + type DevicePlatformAvailability, + type DeviceServiceState, + type EasDeviceHostConfig, + type EnvironmentId, + type ServerSettingsPatch, + type SimlockDeviceHostConfig, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { ChevronDownIcon, MoreVertical, PlusIcon } from "lucide-react"; + +import { cn, randomUUID } from "../../lib/utils"; +import { deviceEnvironment } from "../../state/device"; +import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; +import { serverEnvironment } from "../../state/server"; +import { readEnvironmentScope } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { AndroidIcon, AppleIcon } from "../Icons"; +import { Button, InlineButton } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Spinner } from "../ui/spinner"; +import { toastManager } from "../ui/toast"; +import { DeviceHostEditor } from "./DeviceHostEditor"; +import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; +import { EasDeviceHostEditor } from "./EasDeviceHostEditor"; +import { + easSessionLimits, + newEasDeviceHost, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsSection } from "./settingsLayout"; +import { settingsScopeEnvironmentLabel } from "./settingsScopeAxis"; +import { SimlockDeviceHostEditor } from "./SimlockDeviceHostEditor"; +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockLeaseLimits, + updateSimlockDeviceHosts, + type SimlockTokenEntry, +} from "./simlockDeviceHostsSettings.logic"; +import { useHostConnectionChecks } from "./useHostConnectionChecks"; + +const deviceHostStatesAtom = environmentAtomList("web-device-host-states", (environmentId) => + deviceEnvironment.state({ environmentId, input: {} }), +); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +/** Something on one environment that is in progress or needs a fix; a working source has none. */ +interface SourceStatus { + readonly tone: "pending" | "warning" | "error"; + readonly label: string; + readonly detail?: string | undefined; + readonly action?: { readonly label: string; readonly run: () => void } | undefined; +} + +/** A source on one environment whose devices are on. */ +interface SourceEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly added: boolean; + readonly status?: SourceStatus | undefined; + /** Unknown until the environment's hub has reached the source. */ + readonly platforms?: ReadonlyArray | undefined; +} + +interface SourceRow { + readonly key: string; + readonly title: string; + readonly kind?: string; + readonly description: ReactNode; + readonly environments: ReadonlyArray; + /** + * One machine or account every environment reaches the same way (an SSH host, EAS, a Simlock + * pool), so its platforms are the same for all of them. Local simulators differ per machine. + */ + readonly shared: boolean; + readonly actions?: ReactNode; +} + +type Editing = + | { + readonly kind: "ssh"; + readonly host: SshDeviceHostConfig; + readonly original: SshDeviceHostConfig | null; + /** Limits the save to these environments, for a host that differs between them. */ + readonly only?: ReadonlySet; + } + | { + readonly kind: "eas"; + readonly host: EasDeviceHostConfig; + readonly isNew: boolean; + /** Where the editor checks the Expo account: an environment that holds the host. */ + readonly environmentId: EnvironmentId; + } + | { readonly kind: "simlock"; readonly host: SimlockDeviceHostConfig; readonly isNew: boolean }; + +/** What a configured host is doing on one environment, when it is not simply working. */ +function hostStatus( + state: DeviceServiceState | undefined, + hostId: string, +): SourceStatus | undefined { + if (!state) return { tone: "pending", label: "Loading" }; + const status = state.hostStatuses[hostId]; + switch (status?.status) { + case "ready": + return status.androidDiscoveryIncomplete + ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } + : undefined; + case "installing": + case "starting": + return { tone: "pending", label: "Starting", detail: status.detail }; + case "failed": + return { tone: "error", label: "Failed", detail: status.detail }; + default: + return undefined; + } +} + +const hostPlatforms = (state: DeviceServiceState | undefined, hostId: string) => + state?.hosts.find((host) => host.id === hostId)?.platforms; + +/** + * Every device source the selected environments can open, one row each. A source configured on + * several environments is one row; how it stands on each lives in the row's breakdown. + */ +export function DeviceSourcesSettings(props: { + readonly supportsEas: boolean; + readonly supportsSimlock: boolean; +}) { + const { scope, environments, connectedEnvironments, environment: primary } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const label = (environment: (typeof environments)[number]) => + settingsScopeEnvironmentLabel(environment, environments); + const targets = environments.map((environment) => ({ + environmentId: environment.environmentId, + label: label(environment), + connected: environment.connection.phase === "connected", + })); + const { testConnection } = useHostConnectionChecks(targets); + const ids = connectedEnvironments.map((environment) => environment.environmentId); + const results = useAtomValue(deviceHostStatesAtom(environmentIdsKey(ids))); + const members = connectedEnvironments.map((environment, index) => { + const result = results[index]; + return { + environmentId: environment.environmentId, + label: label(environment), + settings: environment.serverConfig?.settings, + state: result ? Option.getOrUndefined(AsyncResult.value(result)) : undefined, + }; + }); + // Environments with devices off are left out: the switch above already says so. + const active = members.filter((member) => member.settings?.enableDeviceSupport); + const simlockEnabled = members.some((member) => member.settings?.simlockDeviceHostsEnabled); + // Like every scoped setting, a change reaches the connected environments and skips the rest. + const disabled = projectScope || busy || members.length === 0; + + /** Writes one settings patch per environment; reports the environments it could not change. */ + const write = async ( + patches: ReadonlyArray<{ environmentId: EnvironmentId; patch: ServerSettingsPatch }>, + failedTitle: string, + ) => { + if (projectScope) return false; + const name = (environmentId: EnvironmentId) => + members.find((member) => member.environmentId === environmentId)?.label ?? environmentId; + const unwritable = patches.filter( + (patch) => !readEnvironmentScope(patch.environmentId, AuthSettingsWriteScope), + ); + if (unwritable.length > 0) { + toastManager.add({ + type: "error", + title: failedTitle, + description: `This connection cannot change settings on ${unwritable.map((patch) => name(patch.environmentId)).join(", ")}.`, + }); + return false; + } + setBusy(true); + const outcomes = await Promise.all( + patches.map((patch) => + update({ environmentId: patch.environmentId, input: { patch: patch.patch } }), + ), + ); + setBusy(false); + const failed = patches.flatMap((patch, index) => { + const outcome = outcomes[index]; + return outcome && outcome._tag === "Failure" + ? [`${name(patch.environmentId)}: ${failureMessage(outcome.cause)}`] + : []; + }); + if (failed.length === 0) return true; + toastManager.add({ type: "error", title: failedTitle, description: failed.join("\n") }); + return false; + }; + + const saveSsh = async ( + host: SshDeviceHostConfig, + remove: boolean, + original = host, + only?: ReadonlySet, + ) => { + try { + const saved = await write( + members + .filter((member) => !only || only.has(member.environmentId)) + .map((member) => ({ + environmentId: member.environmentId, + patch: { + deviceHosts: updateDeviceHosts( + member.settings?.deviceHosts ?? [], + host, + remove, + original, + ), + }, + })), + remove ? "Device host not removed everywhere" : "Device host not saved everywhere", + ); + if (saved) setEditing(null); + } catch (error) { + toastManager.add({ + type: "error", + title: "Device host not saved", + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const saveEas = async ( + host: EasDeviceHostConfig, + remove: boolean, + previous?: EasDeviceHostConfig, + ) => { + const keepToken = previous !== undefined && host.accessToken === previous.accessToken; + const saved = await write( + members.map((member) => ({ + environmentId: member.environmentId, + patch: { + easDeviceHosts: updateEasDeviceHosts( + member.settings?.easDeviceHosts ?? [], + host, + remove, + keepToken, + ), + }, + })), + remove ? "EAS host not removed everywhere" : "EAS host not saved everywhere", + ); + if (saved) setEditing(null); + }; + + const simlockEnvironments = members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.simlockDeviceHosts ?? [], + })); + const saveSimlock = async ( + host: SimlockDeviceHostConfig, + tokens: ReadonlyMap, + ) => { + const plan = planSimlockDeviceHostSave(simlockEnvironments, host, tokens); + if ("problem" in plan) return; + const saved = await write( + plan.patches.map((patch) => ({ + environmentId: patch.environmentId, + patch: { simlockDeviceHosts: patch.hosts }, + })), + "Simlock host not saved everywhere", + ); + if (saved) setEditing(null); + }; + const removeSimlock = (host: SimlockDeviceHostConfig) => + void write( + simlockEnvironments + .filter((environment) => environment.hosts.some((candidate) => candidate.id === host.id)) + .map((environment) => ({ + environmentId: environment.environmentId, + patch: { simlockDeviceHosts: updateSimlockDeviceHosts(environment.hosts, host, true) }, + })), + "Simlock host not removed everywhere", + ); + + const [testing, setTesting] = useState>(new Set()); + const testSsh = async (host: SshDeviceHostConfig) => { + setTesting((current) => new Set(current).add(host.id)); + const checked = await testConnection(host).finally(() => + setTesting((current) => { + const next = new Set(current); + next.delete(host.id); + return next; + }), + ); + if (!checked) return; + const failed = targets.filter((target) => checked[target.environmentId]?.status === "failed"); + toastManager.add({ + type: failed.length ? "error" : "success", + title: failed.length + ? `${host.label}: ${failed.length} of ${targets.length} environments failed` + : `${host.label}: connection checks passed`, + description: failed.length + ? failed + .map((target) => { + const result = checked[target.environmentId]; + return `${target.label}: ${result?.status === "failed" ? result.error : "failed"}`; + }) + .join("\n") + : "Connected or already available locally on each selected environment.", + }); + }; + + const retry = (environmentId: EnvironmentId, hostId: string) => + void list({ environmentId, input: { retryHostId: hostId } }); + + const rowActions = (onEdit: () => void, onRemove: () => void, extra?: ReactNode) => ( + <> + + + + } + > + + + + {extra} + + Remove + + + + + ); + + const rows: SourceRow[] = []; + + rows.push({ + key: "local", + shared: false, + title: "Local simulators", + description: + members.length > 1 + ? "Simulators and emulators installed on each environment's own machine." + : "Simulators and emulators installed on this environment's machine.", + environments: active.map((member) => { + const local = member.state?.hosts.find((host) => host.kind === "local"); + const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: local?.platforms, + status: + status?.tone === "error" + ? { + ...status, + action: { + label: "Refresh", + run: () => void list({ environmentId: member.environmentId, input: {} }), + }, + } + : status, + }; + }), + }); + + const grouped = groupDeviceHosts( + members.map((member) => ({ + environmentId: member.environmentId, + label: member.label, + hosts: member.settings?.deviceHosts ?? [], + })), + ); + const sshGroups = + grouped ?? + // Hosts that cannot be matched across environments stay one row per environment. + members.flatMap((member) => + (member.settings?.deviceHosts ?? []).map((host) => ({ + host, + members: [{ environmentId: member.environmentId, label: member.label, host }], + })), + ); + for (const group of sshGroups) { + const host = group.host; + const holders = new Set(group.members.map((member) => member.environmentId)); + rows.push({ + key: `ssh:${host.id}:${group.members.map((member) => member.environmentId).join(",")}`, + shared: true, + title: host.label, + kind: "SSH", + description: host.target, + environments: active.map((member) => { + const held = group.members.find( + (candidate) => candidate.environmentId === member.environmentId, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + const status = testing.has(host.id) + ? ({ tone: "pending", label: "Testing connection" } as const) + : hostStatus(member.state, held.host.id); + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, held.host.id), + status: + status?.tone === "error" && member.state?.supportsHostRetry + ? { + ...status, + label: "Can't connect", + action: { label: "Retry", run: () => retry(member.environmentId, held.host.id) }, + } + : status, + }; + }), + actions: rowActions( + () => + setEditing({ kind: "ssh", host, original: host, ...(grouped ? {} : { only: holders }) }), + () => void saveSsh(host, true, host, holders), + void testSsh(host)}>Test connection, + ), + }); + } + + if (props.supportsEas) { + const easHosts = new Map(); + for (const member of members) + for (const host of member.settings?.easDeviceHosts ?? []) + if (!easHosts.has(host.id)) easHosts.set(host.id, host); + for (const host of easHosts.values()) { + const holders = members.filter((member) => + (member.settings?.easDeviceHosts ?? []).some((candidate) => candidate.id === host.id), + ); + const [firstHolder] = holders; + if (!firstHolder) continue; + const checkOn = ( + holders.find((member) => member.environmentId === primary?.environmentId) ?? firstHolder + ).environmentId; + rows.push({ + key: `eas:${host.id}`, + shared: true, + title: host.label, + kind: "EAS", + description: easSessionLimits(host), + environments: active.map((member) => { + const added = (member.settings?.easDeviceHosts ?? []).some( + (candidate) => candidate.id === host.id, + ); + return { + environmentId: member.environmentId, + label: member.label, + added, + ...(added + ? { + status: hostStatus(member.state, host.id), + platforms: hostPlatforms(member.state, host.id), + } + : {}), + }; + }), + actions: rowActions( + () => setEditing({ kind: "eas", host, isNew: false, environmentId: checkOn }), + () => void saveEas(host, true), + ), + }); + } + } + + if (props.supportsSimlock && simlockEnabled) { + const simlockHosts = new Map(); + for (const environment of simlockEnvironments) + for (const host of environment.hosts) + if (!simlockHosts.has(host.id)) simlockHosts.set(host.id, host); + for (const host of simlockHosts.values()) { + const edit = () => setEditing({ kind: "simlock", host, isNew: false }); + rows.push({ + key: `simlock:${host.id}`, + shared: true, + title: host.label, + kind: "Simlock", + description: `${host.gatewayUrl.replace(/^https?:\/\//, "")} ยท ${simlockLeaseLimits(host)}`, + environments: active.map((member) => { + const held = (member.settings?.simlockDeviceHosts ?? []).find( + (candidate) => candidate.id === host.id, + ); + if (!held) + return { environmentId: member.environmentId, label: member.label, added: false }; + return { + environmentId: member.environmentId, + label: member.label, + added: true, + platforms: hostPlatforms(member.state, host.id), + // A blank token can still resolve to the machine's own, so the host's answer decides. + status: hostStatus(member.state, host.id), + }; + }), + actions: rowActions(edit, () => removeSimlock(host)), + }); + } + } + + const addMenu = ( + + } + > + Add source + + + + + setEditing({ + kind: "ssh", + host: { kind: "ssh", id: randomUUID(), label: "", target: "" }, + original: null, + }) + } + > + Machine over SSH + + {props.supportsEas ? ( + + primary && + setEditing({ + kind: "eas", + host: newEasDeviceHost(randomUUID()), + isNew: true, + environmentId: primary.environmentId, + }) + } + > + EAS Simulator + + ) : null} + {props.supportsSimlock && simlockEnabled ? ( + + setEditing({ kind: "simlock", host: newSimlockDeviceHost(randomUUID()), isNew: true }) + } + > + Simlock pool (alpha) + + ) : null} + + + ); + + return ( + + {rows.map((row) => ( + + ))} + {editing?.kind === "ssh" ? ( + void saveSsh(host, false, editing.original ?? host, editing.only)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "eas" ? ( + void saveEas(host, false, editing.isNew ? undefined : editing.host)} + onClose={() => setEditing(null)} + /> + ) : editing?.kind === "simlock" ? ( + void saveSimlock(host, tokens)} + onClose={() => setEditing(null)} + /> + ) : null} + + ); +} + +const PLATFORMS = [ + { platform: "ios", label: "iOS", Icon: AppleIcon }, + { platform: "android", label: "Android", Icon: AndroidIcon }, +] as const satisfies ReadonlyArray<{ platform: DevicePlatform; label: string; Icon: unknown }>; + +/** The platforms a source offers: one icon each, solid where available, muted where not. */ +function SourcePlatforms(props: { row: SourceRow }) { + const added = props.row.environments.filter((environment) => environment.added); + const known = added.filter((environment) => environment.platforms !== undefined); + return PLATFORMS.filter(({ platform }) => + known.some((environment) => + environment.platforms?.some((candidate) => candidate.platform === platform), + ), + ).map((entry) => ); +} + +function PlatformIcon(props: { + platform: DevicePlatform; + label: string; + Icon: (typeof PLATFORMS)[number]["Icon"]; + row: SourceRow; + known: ReadonlyArray; +}) { + const availability = (environment: SourceEnvironment) => + environment.platforms?.find((candidate) => candidate.platform === props.platform); + const availableOn = props.known.filter((environment) => availability(environment)?.available); + const several = props.row.environments.length > 1; + const available = availableOn.length > 0; + const partial = available && availableOn.length < props.known.length; + // A shared source usually answers the same everywhere, so its answer is given once. Each + // environment still signs in to it on its own, which is where they can differ. + const sharedAnswer = props.known.map(availability).find(Boolean); + const uniform = props.known.every( + (environment) => + availability(environment)?.available === sharedAnswer?.available && + availability(environment)?.reason === sharedAnswer?.reason, + ); + const unavailableOn = props.known.filter((environment) => !availability(environment)?.available); + const connected = props.row.environments.filter((environment) => environment.added); + const notAdded = props.row.environments.filter((environment) => !environment.added); + return ( + + + } + > + + {partial ? ( + + ) : null} + + +

+ {props.label}{" "} + {available ? (partial ? "available on some" : "available") : "not available"} +

+ {props.row.shared ? ( + <> + {uniform && !available && sharedAnswer?.reason ? ( +

{sharedAnswer.reason}

+ ) : null} + {several ? ( +
+ {( + [ + ...(uniform + ? ([["Connected", connected]] as const) + : ([ + ["Available", availableOn], + ["Not available", unavailableOn], + ] as const)), + ["Not added", notAdded], + ] as const + ).map(([heading, environments]) => + environments.length > 0 ? ( +
+
{heading}
+ {environments.map((environment) => { + const reason = + heading === "Not available" + ? availability(environment)?.reason + : undefined; + return ( +
+ {environment.label} + {reason ? ( + {reason} + ) : null} +
+ ); + })} +
+ ) : null, + )} +
+ ) : null} + + ) : several ? ( +
+ {props.known.map((environment) => { + const entry = availability(environment); + return ( + +
{environment.label}
+
+ {entry?.available ? "Available" : (entry?.reason ?? "Not available")} +
+
+ ); + })} +
+ ) : !available ? ( +

+ {availability(props.known[0]!)?.reason ?? "Not set up on this machine."} +

+ ) : null} +
+
+ ); +} + +function DeviceSourceRow(props: { row: SourceRow }) { + const { row } = props; + const several = row.environments.length > 1; + const issues = row.environments.filter( + (environment): environment is SourceEnvironment & { status: SourceStatus } => + environment.status !== undefined, + ); + const problems = issues.filter((environment) => environment.status.tone !== "pending"); + const pending = issues.find((environment) => environment.status.tone === "pending"); + return ( +
+
+
+

{row.title}

+ {row.kind ? ( + + {row.kind} + + ) : null} +
+

{row.description}

+ {problems.map((environment) => ( +

+ + {several ? `${environment.label}: ` : ""} + {!several && environment.status.detail + ? environment.status.detail + : environment.status.label} + + {environment.status.action ? ( + + {environment.status.action.label} + + ) : null} +

+ ))} +
+ {pending ? ( + + + {pending.status.label} + + ) : ( +
+ +
+ )} + {row.actions ?
{row.actions}
: null} +
+ ); +} diff --git a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx b/apps/web/src/components/settings/EasDeviceHostEditor.tsx similarity index 72% rename from apps/web/src/components/settings/EasDeviceHostsSettings.tsx rename to apps/web/src/components/settings/EasDeviceHostEditor.tsx index 43c80cdb740c..220f5ffa3269 100644 --- a/apps/web/src/components/settings/EasDeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/EasDeviceHostEditor.tsx @@ -3,17 +3,13 @@ import { useId, useRef, useState } from "react"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { - AuthSettingsWriteScope, type DeviceEasAccount, type EasDeviceHostConfig, type EnvironmentId, } from "@t3tools/contracts"; -import { CloudIcon, PlusIcon, XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; -import { randomUUID } from "../../lib/utils"; import { deviceEasAccountCheck, deviceEnvironment } from "../../state/device"; -import { serverEnvironment } from "../../state/server"; -import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { @@ -28,16 +24,8 @@ import { import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Spinner } from "../ui/spinner"; -import { toastManager } from "../ui/toast"; -import { - easSessionLimits, - newEasDeviceHost, - parseEasDeviceHostDraft, - updateEasDeviceHosts, -} from "./easDeviceHostsSettings.logic"; +import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; import { SecretInput } from "./SecretInput"; -import { useSettingsScope } from "./SettingsScopeContext"; -import { SettingsRow } from "./settingsLayout"; const FROM_THREAD_PROJECT = "thread-project"; @@ -52,116 +40,13 @@ const failureMessage = (cause: Cause.Cause) => { return error instanceof Error ? error.message : String(error); }; -/** EAS hosts belong to one environment: its server holds the Expo credential and runs the sessions. */ -export function EasDeviceHostsSettings(props: { environmentId: EnvironmentId | null }) { - const { scope, connectedEnvironments } = useSettingsScope(); - const canConfigure = useEnvironmentScope(props.environmentId, AuthSettingsWriteScope); - const projectScope = scope.kind === "project" || scope.kind === "checkout"; - const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); - const environment = connectedEnvironments.find( - (candidate) => candidate.environmentId === props.environmentId, - ); - const hosts = environment?.serverConfig?.settings.easDeviceHosts ?? []; - const [editing, setEditing] = useState<{ host: EasDeviceHostConfig; isNew: boolean } | null>( - null, - ); - const [busy, setBusy] = useState(false); - const disabled = !canConfigure || projectScope || busy || !props.environmentId; - - const save = async (host: EasDeviceHostConfig, remove: boolean) => { - if (!props.environmentId || !readEnvironmentScope(props.environmentId, AuthSettingsWriteScope)) - return; - setBusy(true); - const result = await update({ - environmentId: props.environmentId, - input: { patch: { easDeviceHosts: updateEasDeviceHosts(hosts, host, remove) } }, - }); - setBusy(false); - if (result._tag === "Success") setEditing(null); - else - toastManager.add({ - type: "error", - title: remove ? "EAS host not removed" : "EAS host not saved", - description: failureMessage(result.cause), - }); - }; - - return ( - setEditing({ host: newEasDeviceHost(randomUUID()), isNew: true })} - > - Add EAS host - - } - > -
- {hosts.length === 0 ? ( -

No EAS hosts.

- ) : ( -
    - {hosts.map((host) => ( -
  • -
    -

    - {host.label} -

    -

    {easSessionLimits(host)}

    -
    -
    - - -
    -
  • - ))} -
- )} - {editing && props.environmentId ? ( - void save(host, false)} - onClose={() => setEditing(null)} - /> - ) : null} -
-
- ); -} - type AccountCheck = | { status: "idle" } | { status: "checking" } | { status: "signed-in"; account: DeviceEasAccount } | { status: "failed"; error: string }; -function EasDeviceHostEditor(props: { +export function EasDeviceHostEditor(props: { environmentId: EnvironmentId; host: EasDeviceHostConfig; isNew: boolean; diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 58e5d6877c82..5239f57bfdc3 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -2,8 +2,7 @@ import { DeviceHostUpdates } from "../device/DeviceHostUpdates"; import { DeviceToolVersions } from "../device/DeviceToolVersions"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { ScopedSwitch } from "./ScopedSwitch"; -import { DeviceHostsSettings } from "./DeviceHostsSettings"; -import { EasDeviceHostsSettings } from "./EasDeviceHostsSettings"; +import { DeviceSourcesSettings } from "./DeviceSourcesSettings"; import { AuthSettingsWriteScope, BrowserImportFailureReason, @@ -42,7 +41,6 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; -import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; @@ -58,11 +56,6 @@ import { AgentDeviceSetupStatus, DeviceHubSetupStatus, deviceHubReadiness, - PlatformStatus, - platformSetupStatus, - deviceHubDescription, - agentDeviceDescription, - DeviceHostDiagnostics, } from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; @@ -797,15 +790,23 @@ function DeviceIntegrationSettings() { const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; const environmentId = connected ? selected.environmentId : null; + const { state } = useDeviceState(environmentId); + return ( - - + + + + - + ); } @@ -831,20 +832,7 @@ function DeviceIntegrationControls({ const [pending, setPending] = useState< "hub" | "check" | "agent" | "update-hub" | "update-agent" | null >(null); - const { ready: hubReady, busy } = deviceHubReadiness(state); - const localPlatformsUnavailable = state.hosts.some( - (host) => host.kind === "local" && !host.platforms.some((platform) => platform.available), - ); - const [platformsRevealed, setPlatformsRevealed] = useState(false); - if (platformsRevealed && !enabled) setPlatformsRevealed(false); - if ( - enabled && - !platformsRevealed && - (hubReady || localPlatformsUnavailable) && - pending !== "hub" - ) { - setPlatformsRevealed(true); - } + const { busy } = deviceHubReadiness(state); const update = async ( kind: NonNullable, @@ -941,14 +929,18 @@ function DeviceIntegrationControls({ {...searchableSetting("device-hub")} serverScoped settingKeys={["enableDeviceSupport"]} - description={deviceHubDescription} - control={ + description={ <> + Open simulators and emulators in the Device panel. Hub:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "hub" ? : null} } /> - - {platformsRevealed ? ( - 1 - ? `Status for ${connectedEnvironments.find((environment) => environment.environmentId === environmentId)?.label}. Select an environment to inspect its simulator support.` - : undefined - } - status={ -
-
- - -
- {/* Remote hosts report their own warnings in the device host list below. */} - host.kind === "local")} - hostStatuses={state.hostStatuses} - /> -
- } - control={ - - } - /> - ) : null} -
+ New agent sessions can open and drive these devices. agent-device:{" "} host.kind === "local")?.tools} /> + + } + control={ + <> {pending === "agent" ? : null} ) : null} - - {state.supportsEasHosts ? : null} ); } diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 15c943728f14..ce3f68eac49d 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -21,6 +21,7 @@ export function LabsSettingsPanel() { {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } + @@ -148,3 +149,43 @@ function LinkedEnvironmentsRow() { /> ); } + +function SimlockHostsRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + Let the Device panel lease simulators and emulators from your own{" "} +
+ Simlock + {" "} + pool, added in{" "} + + Devices + + . An alpha that may change or break. T3 Code never installs Simlock. + + } + control={ + + updateSettings({ simlockDeviceHostsEnabled: Boolean(checked) }) + } + aria-label="Simlock device hosts" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 843c67dd6026..149764a71de2 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -78,6 +78,14 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ); const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); + const fallbackSelection = settings.fallbackModelSelection ?? selection; + const fallbackModelOptions = getCustomModelOptionsByInstance( + settings, + providers, + fallbackSelection?.instanceId, + fallbackSelection?.model, + ); const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); @@ -217,6 +225,59 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting /> ); + const fallbackRow = !isProjectScope ? ( + updateSettings({ fallbackModelSelection: null })} + /> + ) : null + } + control={ + fallbackSelection ? ( + { + const reason = modelDisabledReason(instanceId, model); + if (reason) { + toastManager.add({ + type: "error", + title: "Fallback model not saved", + description: reason, + }); + return; + } + updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + }} + /> + ) : ( + No providers available + ) + } + /> + ) : null; + return ( {modelRow} + {fallbackRow} {workspaceRow} ) : category === "general" ? ( <> {modelRow} + {fallbackRow} ({ providers: null as unknown as Atom.Writable>, + providerAuthQuery: null as unknown, + providerAuth: null as ProviderAuthState | null, settings: null as unknown as UnifiedSettings, canManageProviders: true, canWriteSettings: true, @@ -40,6 +43,8 @@ vi.mock(import("../../state/server"), async (original) => { const providers = Atom.make>([]).pipe(Atom.keepAlive); state.providers = providers; const config = Atom.make(null); + const providerAuth = Atom.make(null); + state.providerAuthQuery = providerAuth; const commandLabels = { refreshProviders: "refresh", updateProvider: "update", @@ -59,6 +64,7 @@ vi.mock(import("../../state/server"), async (original) => { ...actual.serverEnvironment, ...labelled, configValueAtom: (() => config) as never, + providerAuthState: (() => providerAuth) as never, providersValueAtom: (() => providers) as never, } as never, }; @@ -75,8 +81,8 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ })); vi.mock(import("../../state/query"), async (original) => ({ ...(await original()), - useEnvironmentQuery: (() => ({ - data: null, + useEnvironmentQuery: ((query: unknown) => ({ + data: query === state.providerAuthQuery ? state.providerAuth : null, error: null, isPending: false, refresh: vi.fn(), @@ -216,6 +222,7 @@ async function setup() { state.canManageProviders = true; state.canWriteSettings = true; state.searchTargetId = null; + state.providerAuth = null; state.readEnvironmentIds = []; state.updateEnvironmentIds = []; state.mutationEnvironmentIds = []; @@ -302,4 +309,51 @@ describe("EnvironmentProviderSettings routing", () => { input: { instanceId, elicitationId: action.elicitationId }, }); }); + + it("lets a signed-out managed Codex account switch to the existing CLI", async () => { + await setup(); + const codex = ProviderDriverKind.make("codex"); + state.settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "managed" }, + }, + }, + }; + state.providerAuth = { + instanceId: codexId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + methods: [{ id: "chatgpt-profile:work", name: "Work", description: null, type: "agent" }], + }; + await setProviders([ + { + ...provider(), + auth: { status: "unauthenticated" }, + setup: { canAuthenticate: true, canInstall: true }, + }, + ]); + await using _screen = await render(panel({ targetInstanceId: codexId })); + await expect.element(page.getByRole("button", { name: "Reconnect account" })).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "Use existing CLI" }).element()); + + expect(state.mutateProviderInstance).toHaveBeenCalledWith( + { + operation: "upsert", + instanceId: codexId, + instance: { + driver: codex, + enabled: true, + config: { enabled: true, setupMode: "existing" }, + }, + }, + {}, + ); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index 31eb1f4fc445..f388a7a03e90 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -1,3 +1,5 @@ +import { RegistryContext } from "@effect/atom-react"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { DEFAULT_UNIFIED_SETTINGS, EnvironmentId, @@ -10,7 +12,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; const environmentId = EnvironmentId.make("laptop"); @@ -39,7 +41,23 @@ const task: ScheduledTask = { target: "local", }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const secondaryId = EnvironmentId.make("secondary"); +const permissions = new Map>(); + +function setup() { + state.connected = true; + state.tasksLoaded = true; + state.secondary = false; + permissions.clear(); + permissions.set(environmentId, Atom.make(true)); + permissions.set(secondaryId, Atom.make(true)); + permissions.set("", Atom.make(false)); +} + +function secondaryEnvironment() { + return { ...environment(), environmentId: secondaryId, label: "Secondary" }; +} function environment() { return { @@ -55,8 +73,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { const entry = environment(); return { scope: { kind: "all", label: "All", members: [], environmentIds: [environmentId] }, - environments: [entry], - connectedEnvironments: state.connected ? [entry] : [], + environments: state.secondary ? [entry, secondaryEnvironment()] : [entry], + connectedEnvironments: state.connected + ? state.secondary + ? [entry, secondaryEnvironment()] + : [entry] + : [], environment: entry, target: null, targets: [], @@ -69,15 +91,14 @@ vi.mock(import("./SettingsScopeContext"), async (original) => { }); vi.mock(import("../../state/server"), async (original) => { const actual = await original(); - const { Atom } = await import("effect/reactivity"); - const allowed = Atom.make(true); + return { ...actual, serverEnvironment: { ...actual.serverEnvironment, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, - permissionAtom: () => allowed, + permissionAtom: (id: string | null) => permissions.get(id ?? "")!, }, }, }; @@ -94,7 +115,8 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: ((id: EnvironmentId) => + id === secondaryId ? secondaryEnvironment() : environment()) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -106,9 +128,44 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ })); describe("ScheduledTasksSettings", () => { + it("creates on a permitted secondary and reacts to permission changes", async () => { + setup(); + state.secondary = true; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + registry.set(permissions.get(environmentId)!, false); + await using screen = await render( + + + , + ); + const create = screen.getByRole("button", { name: "New task" }); + await expect.element(create).toBeEnabled(); + await act(() => registry.set(permissions.get(secondaryId)!, false)); + await expect.element(create).toBeDisabled(); + await act(() => registry.set(permissions.get(secondaryId)!, true)); + await expect.element(create).toBeEnabled(); + await create.click(); + await expect.element(page.getByLabelText("Server")).toHaveTextContent("Secondary"); + }); + + it("keeps the permitted representative as the creation target", async () => { + setup(); + state.secondary = true; + await using screen = await render(); + await screen.getByRole("button", { name: "New task" }).click(); + await expect.element(page.getByLabelText("Server")).toHaveTextContent("Laptop"); + }); + + it("disables creation without a connected target", async () => { + setup(); + state.connected = false; + await using screen = await render(); + await expect.element(screen.getByRole("button", { name: "New task" })).toBeDisabled(); + }); + it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -125,7 +182,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 046303831d84..8adf0d88c3dd 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -398,9 +398,18 @@ export function ScheduledTasksSettings(target: { replace: true, }); }; - const canCreate = useAtomValue( - serverEnvironment.upsertScheduledTask.permissionAtom(defaultEnvironment?.environmentId ?? null), + const createPermissions = useAtomValue( + scheduledTaskCreatePermissionsAtom( + environmentIdsKey(connectedEnvironments.map((entry) => entry.environmentId)), + ), ); + const permittedEnvironments = connectedEnvironments.filter( + (_entry, index) => createPermissions[index], + ); + const creationEnvironment = + permittedEnvironments.find( + (entry) => entry.environmentId === defaultEnvironment?.environmentId, + ) ?? permittedEnvironments[0]; return ( - defaultEnvironment && - setEditor({ environmentId: defaultEnvironment.environmentId, task: null }) + creationEnvironment && + setEditor({ environmentId: creationEnvironment.environmentId, task: null }) } > @@ -466,6 +475,11 @@ export function ScheduledTasksSettings(target: { ); } +const scheduledTaskCreatePermissionsAtom = environmentAtomList( + "web-scheduled-task-create-permissions", + serverEnvironment.upsertScheduledTask.permissionAtom, +); + const scheduledTasksListAtom = environmentAtomList("web-scheduled-tasks-list", (environmentId) => serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); diff --git a/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts b/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts new file mode 100644 index 000000000000..a23ea149d480 --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.logic.test.ts @@ -0,0 +1,51 @@ +import { SecretProviderId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import { describe, expect, it } from "vite-plus/test"; + +import { secretProviderFromDraft } from "./SecretProviderDialog.logic"; + +describe("secretProviderFromDraft", () => { + it("adds a provider keyed by its name and reports every field that blocks it", () => { + expect( + secretProviderFromDraft({ name: " 1Password! ", account: "my.1password.com" }, new Set(), { + mode: "add", + }), + ).toEqual( + Result.succeed({ + id: "1password", + config: { + kind: "one_password", + label: "1Password!", + config: { account: "my.1password.com" }, + }, + }), + ); + + const blocked = secretProviderFromDraft( + { name: "1Password", account: "--help" }, + new Set(["1password"]), + { mode: "add" }, + ); + expect(Result.isFailure(blocked) && Object.keys(blocked.failure).toSorted()).toEqual([ + "account", + "name", + ]); + }); + + it("keeps the id when an edit renames the provider", () => { + const id = SecretProviderId.make("personal"); + const result = secretProviderFromDraft( + { name: "Personal (team)", account: "team.1password.com" }, + new Set(["personal"]), + { mode: "edit", id }, + ); + expect(Result.isSuccess(result) && result.success).toEqual({ + id, + config: { + kind: "one_password", + label: "Personal (team)", + config: { account: "team.1password.com" }, + }, + }); + }); +}); diff --git a/apps/web/src/components/settings/SecretProviderDialog.logic.ts b/apps/web/src/components/settings/SecretProviderDialog.logic.ts new file mode 100644 index 000000000000..11139d9c7aa6 --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.logic.ts @@ -0,0 +1,117 @@ +import { + OnePasswordSecretProviderConfig, + SecretProviderId, + SecretProviderKind, + SecretProviderLabel, + type OnePasswordAccount, + type SecretProviderConfig, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +const decodeSecretProviderId = Schema.decodeOption(SecretProviderId); +const decodeOnePasswordConfig = Schema.decodeUnknownOption(OnePasswordSecretProviderConfig); + +export type ClassifiedSecretProvider = + | { readonly kind: "one_password"; readonly account: OnePasswordAccount } + | { readonly kind: "unsupported" }; + +/** Unknown kinds and configs that no longer decode are shown read-only; removal stays available. */ +export function classifySecretProvider(provider: SecretProviderConfig): ClassifiedSecretProvider { + if (provider.kind !== "one_password") return { kind: "unsupported" }; + const decoded = decodeOnePasswordConfig(provider.config); + return decoded._tag === "Some" + ? { kind: "one_password", account: decoded.value.account } + : { kind: "unsupported" }; +} + +/** Stable and readable in settings.json, so re-adding a provider under the same name restores its variables. */ +function secretProviderIdFromName(name: string): SecretProviderId | null { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 64) + .replace(/-$/, ""); + return Option.getOrNull(decodeSecretProviderId(slug)); +} + +export interface SecretProviderDraft { + readonly name: string; + readonly account: string; +} + +export function draftFromSecretProvider(provider: SecretProviderConfig): SecretProviderDraft { + const classified = classifySecretProvider(provider); + return { + name: provider.label, + account: classified.kind === "one_password" ? classified.account : "", + }; +} + +export type SecretProviderTarget = + | { readonly mode: "add" } + | { readonly mode: "edit"; readonly id: SecretProviderId }; + +export type SecretProviderDraftIssues = { + readonly [Field in keyof SecretProviderDraft]?: string; +}; + +function decodeField>( + schema: S, + value: string, + emptyMessage: string, +): Result.Result { + const trimmed = value.trim(); + if (trimmed.length === 0) return Result.fail(emptyMessage); + return Result.mapError(Schema.decodeResult(schema)(trimmed), (error) => error.message); +} + +/** The settings entry a draft adds or replaces, or every field that blocks it, one message each. */ +export function secretProviderFromDraft( + draft: SecretProviderDraft, + existingIds: ReadonlySet, + target: SecretProviderTarget, +): Result.Result< + { readonly id: SecretProviderId; readonly config: SecretProviderConfig }, + SecretProviderDraftIssues +> { + const label = decodeField(SecretProviderLabel, draft.name, "Enter a name."); + const account = decodeField( + OnePasswordSecretProviderConfig.fields.account, + draft.account, + "Enter the 1Password account.", + ); + + let id: SecretProviderId | null = null; + let nameIssue: string | undefined; + if (Result.isFailure(label)) { + nameIssue = label.failure; + } else { + id = target.mode === "edit" ? target.id : secretProviderIdFromName(label.success); + if (id === null) nameIssue = "The name needs at least one letter or digit."; + else if (target.mode === "add" && existingIds.has(id)) + nameIssue = "A secret provider with this name exists."; + } + + if ( + id === null || + nameIssue !== undefined || + Result.isFailure(label) || + Result.isFailure(account) + ) { + return Result.fail({ + ...(nameIssue === undefined ? {} : { name: nameIssue }), + ...(Result.isFailure(account) ? { account: account.failure } : {}), + }); + } + return Result.succeed({ + id, + config: { + kind: SecretProviderKind.make("one_password"), + label: label.success, + config: { account: account.success }, + }, + }); +} diff --git a/apps/web/src/components/settings/SecretProviderDialog.tsx b/apps/web/src/components/settings/SecretProviderDialog.tsx new file mode 100644 index 000000000000..9370fcc68b3e --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.tsx @@ -0,0 +1,155 @@ +import type { EnvironmentId, SecretProviderConfig, SecretProviderId } from "@t3tools/contracts"; +import * as Result from "effect/Result"; +import { useState } from "react"; + +import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { + draftFromSecretProvider, + secretProviderFromDraft, + type SecretProviderDraft, +} from "./SecretProviderDialog.logic"; + +/** Mounted per open, so each open starts from a fresh draft. */ +export function SecretProviderDialog({ + onClose, + environmentId, + environmentLabel, + existingIds, + existing, +}: { + readonly onClose: () => void; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly existingIds: ReadonlySet; + readonly existing?: { readonly id: SecretProviderId; readonly config: SecretProviderConfig }; +}) { + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [draft, setDraft] = useState( + existing ? draftFromSecretProvider(existing.config) : { name: "", account: "" }, + ); + const [submitted, setSubmitted] = useState(false); + const result = secretProviderFromDraft( + draft, + existingIds, + existing ? { mode: "edit", id: existing.id } : { mode: "add" }, + ); + const issues = Result.isFailure(result) ? result.failure : {}; + const nameError = submitted ? issues.name : undefined; + const accountError = submitted ? issues.account : undefined; + + const update = (patch: Partial) => setDraft({ ...draft, ...patch }); + const save = () => { + setSubmitted(true); + if (Result.isFailure(result)) return; + updateSettings({ secretProviders: { [result.success.id]: result.success.config } }); + onClose(); + }; + + return ( + { + if (!next) onClose(); + }} + > + + + + {existing ? `Edit ${existing.config.label}` : "Add a 1Password provider"} + + + Provider variables on {environmentLabel} can read values from it by name. Values are + read with the op CLI on that server when a provider starts. + + + +
{ + event.preventDefault(); + save(); + }} + > +
+ + update({ name: event.target.value })} + maxLength={64} + aria-invalid={nameError !== undefined} + aria-describedby={ + nameError !== undefined ? "secret-provider-name-error" : undefined + } + autoFocus + /> + {nameError !== undefined ? ( + + ) : null} +
+
+ + update({ account: event.target.value })} + maxLength={253} + spellCheck={false} + aria-invalid={accountError !== undefined} + aria-describedby={ + accountError !== undefined ? "secret-provider-account-error" : undefined + } + /> + {accountError !== undefined ? ( + + ) : null} +

+ The account shorthand, sign-in address without https://, or ID that op account list + shows. +

+
+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/SecretsSettings.tsx b/apps/web/src/components/settings/SecretsSettings.tsx new file mode 100644 index 000000000000..2ff329ca6b73 --- /dev/null +++ b/apps/web/src/components/settings/SecretsSettings.tsx @@ -0,0 +1,229 @@ +import type { SecretProviderConfig, SecretProviderId } from "@t3tools/contracts"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; + +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { useEnvironmentSessionState } from "../../state/session"; +import { OnePasswordIcon, type Icon } from "../Icons"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { SecretProviderDialog } from "./SecretProviderDialog"; +import { classifySecretProvider } from "./SecretProviderDialog.logic"; +import { + classifyProviderEnvironmentAccess, + resolveRemoteOperateAccess, +} from "./ProviderSettingsPanel.logic"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { searchableSetting } from "./settingsSearch"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const SECRET_PROVIDER_KINDS: Readonly< + Record +> = { + one_password: { name: "1Password", icon: OnePasswordIcon }, +}; + +export function SecretsSettingsPanel() { + const { scope, environment } = useSettingsScope(); + const connected = environment?.connection.phase === "connected"; + return ( + + {connected && + environment.serverConfig !== null && + environment.serverConfig.secretProviderSettings !== true ? ( + +

+ Update T3 Code on {environment.label} to manage its secret providers. +

+
+ ) : connected ? ( + + ) : ( + +

+ Connect an environment to manage the secret providers its provider variables read from. +

+
+ )} +
+ ); +} + +function EnvironmentSecretProviders({ + environment, + projectScoped, +}: { + readonly environment: EnvironmentPresentation; + readonly projectScoped: boolean; +}) { + const environmentId = environment.environmentId; + const sessionState = useEnvironmentSessionState(environmentId); + // The server requires the same grant for secret providers as for provider settings. + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess: resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }), + }); + const readOnly = access.kind !== "editable" || projectScoped; + const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const [dialog, setDialog] = useState< + null | { readonly mode: "add" } | { readonly mode: "edit"; readonly id: SecretProviderId } + >(null); + const entries = Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>; + const editingConfig = dialog?.mode === "edit" ? providers[dialog.id] : undefined; + const editing = + dialog?.mode === "edit" && editingConfig !== undefined + ? { id: dialog.id, config: editingConfig } + : undefined; + return ( + <> + setDialog({ mode: "add" })}> + + Add provider + + ) : null + } + > + {projectScoped ? ( +

+ Environment-wide setting. Select an environment to change it. +

+ ) : null} + {entries.length === 0 ? ( + + ) : ( + entries.map(([id, config]) => { + const classified = classifySecretProvider(config); + const kind = + classified.kind === "unsupported" + ? undefined + : SECRET_PROVIDER_KINDS[classified.kind]; + return ( + + {kind ? ( + + ) : null} + {config.label} + + } + description={ + classified.kind === "one_password" && kind ? ( + + {kind.name} ยท + + + ) : ( + `Unsupported provider (${config.kind})` + ) + } + control={ + !readOnly ? ( +
+ {kind ? ( + + ) : null} + updateSettings({ secretProviders: { [id]: null } })} + /> +
+ ) : null + } + /> + ); + }) + )} +
+ {dialog !== null && !readOnly && (dialog.mode === "add" || editing !== undefined) ? ( + setDialog(null)} + environmentId={environmentId} + environmentLabel={environment.label} + existingIds={new Set(Object.keys(providers))} + {...(editing === undefined ? {} : { existing: editing })} + /> + ) : null} + + ); +} + +function RemoveSecretProviderButton({ + label, + onConfirm, +}: { + readonly label: string; + readonly onConfirm: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + + + + Remove {label}? + + Provider variables that read from {label} stop resolving until a provider with the + same name is added again. The secrets themselves are untouched. + + + + }>Cancel + + + + + + ); +} diff --git a/apps/web/src/components/settings/SessionPermissions.tsx b/apps/web/src/components/settings/SessionPermissions.tsx index 1cbff3d750cf..30f61ddd8037 100644 --- a/apps/web/src/components/settings/SessionPermissions.tsx +++ b/apps/web/src/components/settings/SessionPermissions.tsx @@ -1,5 +1,8 @@ import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; -import { AUTH_SCOPE_OPTIONS } from "@t3tools/shared/authScopeOptions"; +import { + AUTH_SCOPE_OPTIONS, + sessionPermissionsUnavailableMessage, +} from "@t3tools/shared/authScopeOptions"; import { useEnvironmentSessionState } from "~/state/session"; export function SessionPermissions({ @@ -32,7 +35,11 @@ export function SessionPermissions({ ) : (

- Permissions not checked. Connect to this environment to view this sessionโ€™s permissions. + {sessionPermissionsUnavailableMessage({ + connected, + failed: session.hasError, + rejected: !session.hasError && !session.isPending && session.data?.authenticated === false, + })}

); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 52956b23eb4b..f450443463be 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { CheckIcon } from "lucide-react"; @@ -20,6 +21,7 @@ import { type QuitConfirmationMode, SidebarProjectSortOrder, } from "@t3tools/contracts/settings"; +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { createModelSelection } from "@t3tools/shared/model"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; @@ -64,7 +66,7 @@ import { import { providerInstanceEntriesFor } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { useEnvironmentProviders } from "../../state/environments"; +import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; @@ -95,6 +97,13 @@ import { BackgroundActivitySettingsRow } from "./BackgroundActivitySettings"; import { AdvancedSettings } from "./AdvancedSettings"; import { PullRequestLinksOpenInAppSetting } from "./IntegrationsSettings"; import { StorageCleanupSections } from "./StorageSettings"; +import { UpdateTrackSwitchDialog } from "./UpdateTrackSwitchDialog"; +import { environmentIdsKey } from "~/state/environmentAtomList"; +import { environmentSessionStatesAtom } from "~/state/session"; +import { canUpdateServer } from "../ServerUpdateAction"; +import { useUpdateEverythingRun } from "../sidebar/updateEverythingRun"; +import { awaitsTrackSwitch, channelSwitchLabel } from "../sidebar/updateEverything.logic"; +import { isHostedByThisApp } from "../sidebar/useUpdateEverything"; const RESPONSE_STREAMING_MODE_LABELS: Record = { turn: "Wait for the full response", @@ -142,39 +151,38 @@ function AboutVersionTitle() { function AboutVersionSection() { const updateState = useDesktopUpdateState(); - const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); + const [switchChannel, setSwitchChannel] = useState(null); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); + const { environments } = useEnvironments(); + const sessionStates = useAtomValue( + environmentSessionStatesAtom( + environmentIdsKey(environments.map(({ environmentId }) => environmentId)), + ), + ); + // A second run would replace the first run's progress and local download. + const updateRun = useUpdateEverythingRun(); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; - - const handleUpdateChannelChange = (channel: DesktopUpdateChannel) => { - const bridge = window.desktopBridge; - if ( - !bridge || - typeof bridge.setUpdateChannel !== "function" || - channel === selectedUpdateChannel - ) { - return; - } - - setIsChangingUpdateChannel(true); - void bridge - .setUpdateChannel(channel) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not change update track", - description: error instanceof Error ? error.message : "Update track change failed.", - }), - ); - }) - .finally(() => { - setIsChangingUpdateChannel(false); - }); - }; + // A switch that failed part way leaves this app or a server on the other track. + // With its updater off, a switch cannot move this app, so it is not counted. + const appOffTrack = + updateState?.enabled !== false && desktopUpdateChannelOf(APP_VERSION) !== selectedUpdateChannel; + const unswitchedMachines = + (appOffTrack ? 1 : 0) + + environments.filter( + (environment, index) => + environment.connection.phase === "connected" && + canUpdateServer(sessionStates[index]!) && + awaitsTrackSwitch( + { + serverConfig: environment.serverConfig, + hostedByThisApp: isHostedByThisApp(environment), + }, + selectedUpdateChannel, + ), + ).length; const handleButtonClick = async () => { const bridge = window.desktopBridge; @@ -272,7 +280,8 @@ function AboutVersionSection() { const statusLabel: Record = { checking: "Checkingโ€ฆ", downloading: "Downloadingโ€ฆ", - "up-to-date": "Up to Date", + // Off its track, "up to date" only means no switch is pending; Review moves it. + ...(appOffTrack ? {} : { "up-to-date": "Up to Date" }), }; const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; @@ -308,23 +317,43 @@ function AboutVersionSection() { {hasDesktopBridge ? ( 0 ? ( + <> + {unswitchedMachines === 1 + ? "1 machine still runs" + : `${unswitchedMachines} machines still run`}{" "} + {channelSwitchLabel(selectedUpdateChannel === "nightly" ? "latest" : "nightly")}.{" "} + {/* The dialog moves what it can and says why it skips the rest. */} + + + ) : ( + "Switches this app and every connected machine whose database the target release supports." + ) + } control={ { + const value = Number(event.target.value); + setDraft((current) => ({ ...current, [field]: value })); + }} + /> + + ); + + return ( + { + if (!open && !props.busy) props.onClose(); + }} + > + +
{ + event.preventDefault(); + if (problem === null && !props.busy) props.onSave(next, tokens); + }} + > + + {props.isNew ? "Add Simlock host" : "Edit Simlock host"} + + {several + ? "Each selected environment's server leases devices from your Simlock gateway with its own agent token, and releases them when they close." + : "The environment's server leases devices from your Simlock gateway and releases them when they close."} + + + + + +
+ {props.environments.map((environment) => ( + + setTokens((current) => new Map(current).set(environment.environmentId, token)) + } + /> + ))} +

+ Leases, drives, and streams devices. Create one{several ? " per environment" : ""}{" "} + with simlock --gateway token create --role agent, or leave it blank to + add it later. A blank token uses the machine's{" "} + AGENT_DEVICE_DAEMON_AUTH_TOKEN when its{" "} + AGENT_DEVICE_DAEMON_BASE_URL is this gateway. +

+
+
+ {limit("maxLeases", "Devices at once", 1, 20)} + {limit("maxLeaseMinutes", "Lease minutes", 5, 240)} +
+

+ A lease ends after the lease minutes even while in use. + {props.isNew ? null : " Saving restarts this host, which releases its devices."} +

+ {problem === null || !gatewayUrl ? null : ( +

+ {problem} +

+ )} +
+ + + + +
+
+
+ ); +} + +function SimlockTokenField(props: { + environmentId: EnvironmentId; + hostId: string | null; + gatewayUrl: string | null; + label: ReactNode; + token: SimlockTokenEntry; + hasSaved: boolean; + busy: boolean; + onChange: (token: SimlockTokenEntry) => void; +}) { + const id = useId(); + const entered = props.token.kind === "new" ? props.token.value.trim() : ""; + // A cleared field asks about the machine's own token, which a blank one falls back to. + const check = useAtomValue( + props.gatewayUrl === null + ? NO_CHECK + : deviceSimlockIdentityCheck({ + environmentId: props.environmentId, + input: { + gatewayUrl: props.gatewayUrl, + ...(entered + ? { agentToken: entered } + : props.token.kind === "saved" && props.hostId !== null + ? { hostId: props.hostId } + : {}), + }, + }), + ); + const identity = AsyncResult.isSuccess(check) ? check.value : null; + const status = + props.gatewayUrl === null ? null : check._tag === "Initial" || check.waiting ? ( + Checkingโ€ฆ + ) : identity ? ( + + {identity.tokenSource === "env" + ? `Using this machine's agent-device token (${identity.signedInAs}).` + : `Accepted as ${identity.signedInAs}.`} + + ) : AsyncResult.isFailure(check) ? ( + + {failureMessage(check.cause)} + + ) : null; + return ( +
+ +
+ { + const value = event.target.value; + // Emptying the field is not clearing the saved token; the Clear + // button is, so a saved token comes back here. + props.onChange( + value + ? { kind: "new", value } + : props.hasSaved + ? { kind: "saved" } + : { kind: "cleared" }, + ); + }} + /> + {props.token.kind !== "cleared" ? ( + + ) : null} +
+ {status ?

{status}

: null} +
+ ); +} diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 3a6478434c94..2ee0085bbb1c 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -3,6 +3,14 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../../test/render"; +import { + CUSTOM_THEMES_STORAGE_KEY, + getCustomThemes, + installCustomTheme, + invalidateCustomThemes, + parseThemeFile, + THEME_FILE_VERSION, +} from "../../themePalette"; import type { OpenVsxThemeExtension } from "../../openVsxThemes"; import { ThemeImportDialog } from "./ThemeImportDialog"; @@ -54,3 +62,53 @@ describe("ThemeImportDialog", () => { ); }); }); + +const theme = (name: string, accent = "#3b82f6") => ({ + version: THEME_FILE_VERSION, + name, + appearance: "dark", + colors: { accent }, +}); + +const themeFile = (name: string, accent?: string) => + new File([JSON.stringify(theme(name, accent))], `${name}.json`, { type: "application/json" }); + +it.each(["Update existing", "Keep both"])( + "keeps batch failures visible after %s", + async (choice) => { + using _themes = { + [Symbol.dispose]() { + window.localStorage.removeItem(CUSTOM_THEMES_STORAGE_KEY); + invalidateCustomThemes(); + }, + }; + installCustomTheme(parseThemeFile(theme("Dusk"))); + const onOpenChange = vi.fn(); + await using _screen = await render( + true} + onImportedMany={() => {}} + />, + ); + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload(input, [ + themeFile("Dawn"), + themeFile("Dusk", "#f97316"), + new File(["{"], "broken.json", { type: "application/json" }), + ]); + + await expect.element(page.getByText("Already installed")).toBeVisible(); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(getCustomThemes().map((theme) => theme.label)).toContain("Dawn"); + await page.getByRole("button", { name: choice }).click(); + invalidateCustomThemes(); + const label = choice === "Update existing" ? "Dusk" : "Dusk (1)"; + expect(getCustomThemes().find((theme) => theme.label === label)?.colors).toEqual( + parseThemeFile(theme("Dusk", "#f97316")).colors, + ); + await expect.element(page.getByText(/^broken\.json:/)).toBeVisible(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }, +); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 8536498ba5a6..e9d37f45322d 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -284,11 +284,10 @@ function ThemeImportBody({ } } if (installed.length > 0) onImportedMany(installed, { updated: false }); - if (failures.length > 0) { - setError(failures.join("; ")); - } else if (conflicting.length > 0) { + setError(failures.length > 0 ? failures.join("; ") : null); + if (conflicting.length > 0) { setConflicts(conflicting); - } else if (installed.length > 0) { + } else if (installed.length > 0 && failures.length === 0) { onClose(); } }; @@ -387,8 +386,8 @@ function ThemeImportBody({ } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); - if (failures.length > 0) setError(failures.join("; ")); - else onClose(); + if (failures.length > 0) setError([error, ...failures].filter(Boolean).join("; ")); + else if (!error) onClose(); }; const handleSubmit = () => { diff --git a/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx new file mode 100644 index 000000000000..a142155d0357 --- /dev/null +++ b/apps/web/src/components/settings/ThreadDetailsItemsRow.tsx @@ -0,0 +1,53 @@ +import { ChevronDownIcon } from "lucide-react"; + +import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; +import { ThreadDetailsItemsMenuItems } from "../chat/ThreadDetailsItemsMenu"; +import { + isDefaultThreadDetailsVisibility, + resolveThreadDetailsVisibility, + RESET_THREAD_DETAILS_ITEMS, + THREAD_DETAILS_ITEMS, +} from "../chat/threadDetailsItems"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +export function ThreadDetailsItemsRow() { + const visibility = useClientSettings(resolveThreadDetailsVisibility); + const enabled = THREAD_DETAILS_ITEMS.filter((item) => visibility[item.id]).length; + return ( + updateClientSettings(RESET_THREAD_DETAILS_ITEMS)} + /> + ) + } + control={ + + + } + > + {enabled} of {THREAD_DETAILS_ITEMS.length} on + + + + + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx new file mode 100644 index 000000000000..f746cff28c27 --- /dev/null +++ b/apps/web/src/components/settings/UpdateTrackSwitchDialog.tsx @@ -0,0 +1,178 @@ +import { useAtomValue } from "@effect/atom-react"; +import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { DesktopUpdateChannel } from "@t3tools/contracts"; +import { acceptsTrackSwitchRelease, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { isMacPlatform } from "@t3tools/shared/keybindings"; +import { useState } from "react"; +import { AsyncResult } from "effect/reactivity"; + +import { APP_VERSION } from "~/branding"; +import { useDesktopUpdateState } from "~/state/desktopUpdate"; +import { useEnvironments } from "~/state/environments"; +import { npmReleaseAtom } from "~/state/npmRelease"; +import { downloadLocalUpdate, installLocalUpdate } from "../sidebar/SidebarUpdatePill"; +import { UpdateEverythingDialog } from "../sidebar/UpdateEverythingDialog"; +import { + channelSwitchLabel, + localSwitchBlockReason, + type ChannelSwitchRelease, +} from "../sidebar/updateEverything.logic"; +import { isHostedByThisApp, useUpdateEverything } from "../sidebar/useUpdateEverything"; +import { prepareLocalTrackSwitch } from "./updateTrackSwitch.logic"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +/** Moves this app and every connected machine to `channel`. */ +export function UpdateTrackSwitchDialog({ + channel, + onClose, +}: { + readonly channel: DesktopUpdateChannel; + readonly onClose: () => void; +}) { + // npm dist-tags match the desktop channel names. + const release = useAtomValue(npmReleaseAtom(channel)); + if (AsyncResult.isSuccess(release) && release.value !== null) { + return ; + } + const label = channelSwitchLabel(channel); + return ( + !open && onClose()}> + + + Switch to {label} + + {AsyncResult.isFailure(release) || AsyncResult.isSuccess(release) + ? `Could not find the latest ${label} release on npm. Try again later.` + : "Checking releasesโ€ฆ"} + + + + }>Cancel + + + + ); +} + +function SwitchPlan({ + release, + onClose, +}: { + readonly release: ChannelSwitchRelease; + readonly onClose: () => void; +}) { + const updateState = useDesktopUpdateState(); + const { environments } = useEnvironments(); + const crossesLocal = desktopUpdateChannelOf(APP_VERSION) !== release.channel; + // With its updater off (a .deb install, or no feed) this app stays put and only remotes move. + const updatesOff = updateState?.enabled === false; + // After a partial switch this app may already be on the track; then only remotes move. + const switchesLocal = crossesLocal && !updatesOff; + const [checking, setChecking] = useState(false); + const everything = useUpdateEverything(switchesLocal ? release.version : null, release); + const label = channelSwitchLabel(release.channel); + // This app's reinstall restarts every server it hosts, so each of their databases must allow it. + const localBlocked = localSwitchBlockReason( + APP_VERSION, + release, + environments.filter(isHostedByThisApp).map((environment) => ({ + connected: environment.connection.phase === "connected", + databaseMigration: environment.serverConfig?.environment.databaseMigration, + })), + window.desktopBridge?.getLocalEnvironmentBootstraps().length ?? 0, + ); + + const run = async () => { + const bridge = window.desktopBridge; + if (!bridge) return; + // Stay open while the feed is checked; the run's own progress takes over after. + setChecking(true); + const fail = (description: string) => + toastManager.add( + stackedThreadToast({ type: "error", title: `Could not switch to ${label}`, description }), + ); + const prepared = await prepareLocalTrackSwitch(bridge, { + channel: release.channel, + version: release.version, + reinstall: switchesLocal, + }); + onClose(); + if (!prepared.ok) { + fail(prepared.message); + return; + } + const { local } = prepared; + const result = await settlePromise(() => + everything.run( + local === null + ? {} + : { + ...(local.status === "available" + ? { + downloadLocal: (reportError) => + downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), + } + : {}), + // Install only a build the database check covers, pinned so nothing replaces it. + installLocal: async (reportError) => { + const { downloadedVersion } = await bridge.getUpdateState(); + if ( + downloadedVersion === null || + !acceptsTrackSwitchRelease(downloadedVersion, release.channel, release.version) + ) { + reportError( + "Could not install update", + `This app downloaded ${downloadedVersion ?? "nothing"}, not ${release.version} or newer.`, + ); + return false; + } + return installLocalUpdate(bridge, reportError, downloadedVersion); + }, + }, + ), + ); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + fail(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }; + + return ( + !open && !checking && onClose()} + title={`Switch to ${label}?`} + version={release.version} + providerCount={0} + rows={everything.rows} + notes={[ + ...(everything.rows.length === 0 && !crossesLocal + ? [`Every connected machine is already on ${label}. This only saves the track.`] + : []), + ...(crossesLocal && updatesOff + ? ["This app's updates are off here, so reinstall it yourself to switch."] + : []), + ...(switchesLocal && isMacPlatform(navigator.platform) + ? ["If T3 Code does not reopen after it restarts, open it again."] + : []), + ]} + restarts={everything.restarts} + confirmLabel={checking ? "Checkingโ€ฆ" : `Switch to ${label}`} + busy={checking} + disabledReason={ + !switchesLocal || localBlocked === null ? null : `This app cannot switch: ${localBlocked}.` + } + onConfirm={() => void run()} + /> + ); +} diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx index bab7ddd9c52a..f238850ae04d 100644 --- a/apps/web/src/components/settings/UsageProviderSettings.tsx +++ b/apps/web/src/components/settings/UsageProviderSettings.tsx @@ -121,6 +121,7 @@ export function UsageProviderSettings({ onOpenChange={setAdding} environmentId={environmentId} environmentLabel={environmentLabel} + sources={sources} /> ) : null} diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts index 545d1ac212ec..c3105b115e21 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as Option from "effect/Option"; -import { newEasDeviceHost, parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic"; +import { + newEasDeviceHost, + parseEasDeviceHostDraft, + updateEasDeviceHosts, +} from "./easDeviceHostsSettings.logic"; describe("EAS device host settings", () => { it("rejects limits EAS does not allow", () => { @@ -18,4 +22,15 @@ describe("EAS device host settings", () => { true, ); }); + + it("keeps each environment's own token when a save leaves the token unchanged", () => { + const edited = { ...newEasDeviceHost("eas"), label: "Renamed", accessToken: "" }; + const elsewhere = { ...newEasDeviceHost("eas"), accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }; + expect(updateEasDeviceHosts([elsewhere], edited, false, true)).toEqual([ + { ...edited, accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }, + ]); + expect(updateEasDeviceHosts([], { ...edited, accessToken: "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" }, false, true)).toEqual([ + { ...edited, accessToken: "" }, + ]); + }); }); diff --git a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts index 418a15d0064e..d91fe45bb920 100644 --- a/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts +++ b/apps/web/src/components/settings/easDeviceHostsSettings.logic.ts @@ -17,15 +17,22 @@ export const newEasDeviceHost = (id: string): EasDeviceHostConfigType => ({ }); /** Adds, replaces, or removes one EAS host. */ +/** + * With `keepToken`, the environment keeps the token it holds: a saved token arrives redacted, and + * one environment's token says nothing about another's. One new to the host uses its own Expo login. + */ export function updateEasDeviceHosts( hosts: ReadonlyArray, host: EasDeviceHostConfigType, remove: boolean, + keepToken = false, ): ReadonlyArray { if (remove) return hosts.filter((candidate) => candidate.id !== host.id); - return hosts.some((candidate) => candidate.id === host.id) - ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) - : [...hosts, host]; + const existing = hosts.find((candidate) => candidate.id === host.id); + const next = keepToken ? { ...host, accessToken: existing?.accessToken ?? "" } : host; + return existing + ? hosts.map((candidate) => (candidate.id === host.id ? next : candidate)) + : [...hosts, next]; } export function easSessionLimits(host: EasDeviceHostConfigType) { diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 3024c6dba62e..70271c852e28 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -14,12 +14,18 @@ import { isSettingsSearchScopeAvailable, movedSettingsPath, searchSettings, + SETTINGS_NAV_GROUPS, SETTINGS_SEARCH_ITEMS, sourceControlHostSearchItems, sourceControlHostSettingsSearchId, type SettingsSearchItem, } from "./settingsSearch"; +vi.mock("~/env", async (importOriginal) => ({ + ...(await importOriginal()), + isSecretsFeatureEnabled: false, +})); + const ITEMS: ReadonlyArray = [ { id: "word-wrap", @@ -60,6 +66,11 @@ describe("searchSettings", () => { expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); }); + it("hides the Secrets page from nav and search while the secrets feature is disabled", () => { + expect(SETTINGS_NAV_GROUPS.flatMap((group) => group.paths)).not.toContain("/settings/secrets"); + expect(searchSettings("1password").map((item) => item.to)).not.toContain("/settings/secrets"); + }); + it("matches normalized title substrings", () => { expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]); expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 00de3f1bd382..4cca2272921b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,5 +1,5 @@ import { HOSTED_APP_CHANNEL } from "~/branding"; -import { isElectron } from "~/env"; +import { isElectron, isSecretsFeatureEnabled } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; import { STATIC_KEYBINDING_COMMANDS, type KeybindingCommand } from "@t3tools/contracts"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -34,6 +34,7 @@ export type SettingsPath = | "/settings/worktrees" | "/settings/connections" | "/settings/access" + | "/settings/secrets" | "/settings/maintenance" | "/settings/labs" | "/settings/about" @@ -58,6 +59,7 @@ export interface SettingsSearchItem { readonly macOnly?: boolean; readonly windowsOnly?: boolean; readonly releaseChannelOnly?: boolean; + readonly secretsFeatureOnly?: boolean; readonly cloudOnly?: boolean; readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; @@ -106,6 +108,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/worktrees": "Worktrees", "/settings/connections": "Environments", "/settings/access": "Sharing & access", + "/settings/secrets": "Secrets", "/settings/maintenance": "Maintenance", "/settings/labs": "Labs", "/settings/about": "About", @@ -143,7 +146,14 @@ export const SETTINGS_NAV_GROUPS: ReadonlyArray<{ heading: "Code", paths: ["/settings/projects", "/settings/source-control", "/settings/worktrees"], }, - { heading: "Connections", paths: ["/settings/connections", "/settings/access"] }, + { + heading: "Connections", + paths: [ + "/settings/connections", + "/settings/access", + ...(isSecretsFeatureEnabled ? (["/settings/secrets"] as const) : []), + ], + }, { heading: null, paths: ["/settings/maintenance", "/settings/labs", "/settings/about", "/settings/archived"], @@ -337,6 +347,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "thread-details-items", + title: "Thread details", + to: "/settings/appearance", + searchTerms: [ + "customize show hide panel card environment editor scripts branch pull request git changes servers automations lineage usage limits 5 hour weekly quota tokens context window", + ], + }, { id: "effort-display", title: "Effort display", @@ -492,6 +510,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment", searchTerms: ["beta link another machine vps peer agents work there delegate"], }, + { + id: "simlock-hosts", + title: "Simlock device hosts (alpha)", + to: "/settings/labs", + scope: "environment", + searchTerms: ["beta alpha simlock callstack simulator emulator ios android pool lease gateway"], + }, { id: "auto-settle-inactive-threads", title: "Auto-settle inactive threads", @@ -855,31 +880,33 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["allow disable enable open drive preview tools sessions project override"], }, { - id: "device-hosts", - title: "Device hosts", + id: "device-sources", + title: "Device sources", to: "/settings/devices", - searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + searchTerms: [ + "device hosts ssh remote mac mini identity key connection eas expo cloud simlock pool gateway local simulators", + ], }, { id: "agent-device-access", - title: "Agent device access", + title: "Agents can use devices", to: "/settings/devices", targetId: "devices", searchTerms: ["allow simulator emulator ios android drive tools sessions"], }, { id: "device-hub", - title: "Device hub", + title: "Use devices", to: "/settings/devices", targetId: "devices", - searchTerms: ["simulator emulator ios android install start"], + searchTerms: ["device hub simulator emulator ios android install start version"], }, { id: "device-platform-support", - title: "Simulator support", + title: "Local simulators", to: "/settings/devices", - targetId: "devices", - searchTerms: ["xcode android studio sdk avd runtime"], + targetId: "device-sources", + searchTerms: ["simulator support xcode android studio sdk avd runtime"], }, { id: "browser-profiles", @@ -1174,6 +1201,21 @@ export const SETTINGS_SEARCH_ITEMS = [ `pull request routing trusted environments shared credentials permissions read actions source control ${SOURCE_CONTROL_HOST_LABELS}`, ], }, + { + id: "secret-providers", + title: "Secret providers", + to: "/settings/secrets", + searchTerms: ["1password vault credentials api key"], + secretsFeatureOnly: true, + }, + { + id: "secret-providers-add", + title: "Add secret provider", + to: "/settings/secrets", + targetId: "secret-providers", + searchTerms: ["new 1password vault op cli account"], + secretsFeatureOnly: true, + }, { id: "archive", title: "Archived threads", @@ -1204,6 +1246,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly (isElectron || item.desktopOnly !== true) && (!item.releaseChannelOnly || isElectron || HOSTED_APP_CHANNEL !== null) && + (!item.secretsFeatureOnly || isSecretsFeatureEnabled) && (!item.macOnly || isMacPlatform(platform)) && (!item.windowsOnly || isWindowsPlatform(platform)), ); diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts new file mode 100644 index 000000000000..2efe3c78c24e --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + newSimlockDeviceHost, + planSimlockDeviceHostSave, + simlockDeviceHostProblem, + updateSimlockDeviceHosts, +} from "./simlockDeviceHostsSettings.logic"; + +const host = { + ...newSimlockDeviceHost("simlock"), + gatewayUrl: "https://gateway.example.ts.net:4700", + agentToken: "agent", +}; + +describe("Simlock device host settings", () => { + it("names what keeps a draft from saving", () => { + expect(simlockDeviceHostProblem(host)).toBeNull(); + expect(simlockDeviceHostProblem({ ...host, label: " " })).toBe("Give the host a label."); + expect(simlockDeviceHostProblem({ ...host, gatewayUrl: "gateway:4700" })).toMatch(/origin/); + expect( + simlockDeviceHostProblem({ ...host, gatewayUrl: "https://gateway.example/api" }), + ).toMatch(/origin/); + expect(simlockDeviceHostProblem({ ...host, maxLeases: 21 })).toMatch(/ranges/); + expect(simlockDeviceHostProblem({ ...host, maxLeaseMinutes: 4 })).toMatch(/ranges/); + }); + + it("adds, replaces, and removes one host", () => { + const other = { ...host, id: "other", label: "Other" }; + const added = updateSimlockDeviceHosts([other], host, false); + expect(added.map((candidate) => candidate.id)).toEqual(["other", "simlock"]); + const renamed = updateSimlockDeviceHosts(added, { ...host, label: "Renamed" }, false); + expect(renamed.map((candidate) => candidate.label)).toEqual(["Other", "Renamed"]); + expect(updateSimlockDeviceHosts(renamed, host, true)).toEqual([other]); + }); + + it("saves the shared fields everywhere and each environment's own token, if it has one", () => { + const mbp = "mbp" as EnvironmentId; + const cups = "cups" as EnvironmentId; + const environments = [ + { environmentId: mbp, label: "mbp", hosts: [{ ...host, agentToken: "" }] }, + { environmentId: cups, label: "cups", hosts: [] }, + ]; + const draft = { ...host, label: "Pool", agentToken: "" }; + + expect( + planSimlockDeviceHostSave(environments, draft, new Map([[mbp, { kind: "saved" }]])), + ).toEqual({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "" }] }, + ], + }); + expect( + planSimlockDeviceHostSave( + environments, + draft, + new Map([ + [mbp, { kind: "saved" }], + [cups, { kind: "new", value: " cups-token " }], + ]), + ), + ).toEqual({ + patches: [ + { environmentId: mbp, hosts: [{ ...draft, agentToken: "" }] }, + { environmentId: cups, hosts: [{ ...draft, agentToken: "cups-token" }] }, + ], + }); + }); +}); diff --git a/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts new file mode 100644 index 000000000000..a4ef1e1bb90b --- /dev/null +++ b/apps/web/src/components/settings/simlockDeviceHostsSettings.logic.ts @@ -0,0 +1,93 @@ +import { + SimlockDeviceHostConfig, + type EnvironmentId, + type SimlockDeviceHostConfig as SimlockDeviceHostConfigType, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const parseSimlockDeviceHostDraft = Schema.decodeUnknownOption(SimlockDeviceHostConfig); + +export const newSimlockDeviceHost = (id: string): SimlockDeviceHostConfigType => ({ + kind: "simlock", + id, + label: "Simlock", + gatewayUrl: "", + agentToken: "", + maxLeases: 2, + maxLeaseMinutes: 60, +}); + +/** Adds, replaces, or removes one Simlock host. */ +export function updateSimlockDeviceHosts( + hosts: ReadonlyArray, + host: SimlockDeviceHostConfigType, + remove: boolean, +): ReadonlyArray { + if (remove) return hosts.filter((candidate) => candidate.id !== host.id); + return hosts.some((candidate) => candidate.id === host.id) + ? hosts.map((candidate) => (candidate.id === host.id ? host : candidate)) + : [...hosts, host]; +} + +/** Why the shared fields keep a draft from saving, or null; tokens are checked per environment. */ +export function simlockDeviceHostProblem(host: SimlockDeviceHostConfigType): string | null { + const withToken = { ...host, agentToken: "token" }; + if (Option.isSome(parseSimlockDeviceHostDraft(withToken))) return null; + if (!host.label.trim()) return "Give the host a label."; + // With in-range limits, a draft that still fails has a bad gateway URL. + return Option.isNone( + parseSimlockDeviceHostDraft({ ...withToken, maxLeases: 1, maxLeaseMinutes: 60 }), + ) + ? "Enter the gateway's origin, such as https://gateway.example.ts.net:4700." + : "Keep the limits within the ranges shown."; +} + +export function simlockLeaseLimits(host: SimlockDeviceHostConfigType) { + const devices = host.maxLeases === 1 ? "1 device" : `${host.maxLeases} devices`; + return `Up to ${devices} at once, each released after ${host.maxLeaseMinutes} min`; +} + +export interface SimlockHostEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly hosts: ReadonlyArray; +} + +/** A token field: the environment's saved token, a replacement, or none. */ +export type SimlockTokenEntry = + | { kind: "saved" } + | { kind: "new"; value: string } + | { kind: "cleared" }; + +/** + * The host list each environment saves: the shared fields everywhere, and each environment's own + * token, which may stay blank until later. Saved tokens arrive redacted; sending one back keeps it. + */ +export function planSimlockDeviceHostSave( + environments: ReadonlyArray, + draft: SimlockDeviceHostConfigType, + tokens: ReadonlyMap, +): + | { readonly problem: string } + | { + readonly patches: ReadonlyArray<{ + environmentId: EnvironmentId; + hosts: ReadonlyArray; + }>; + } { + const shared = simlockDeviceHostProblem(draft); + if (shared) return { problem: shared }; + const patches = []; + for (const environment of environments) { + const saved = environment.hosts.find((host) => host.id === draft.id)?.agentToken ?? ""; + const entry = tokens.get(environment.environmentId) ?? { kind: "cleared" }; + const agentToken = + entry.kind === "saved" ? saved : entry.kind === "new" ? entry.value.trim() : ""; + patches.push({ + environmentId: environment.environmentId, + hosts: updateSimlockDeviceHosts(environment.hosts, { ...draft, agentToken }, false), + }); + } + return { patches }; +} diff --git a/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts b/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts new file mode 100644 index 000000000000..63c4422f28a6 --- /dev/null +++ b/apps/web/src/components/settings/updateTrackSwitch.logic.test.ts @@ -0,0 +1,76 @@ +import type { DesktopUpdateChannel, DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { prepareLocalTrackSwitch } from "./updateTrackSwitch.logic"; + +const STABLE = "0.1.3"; + +/** Like the desktop app, saving a track re-checks the feed and reports what it offers. */ +function fakeBridge(offer: string | null | Error) { + const saved: DesktopUpdateChannel[] = []; + const base: DesktopUpdateState = { + enabled: true, + status: "up-to-date", + channel: "nightly", + currentVersion: "0.1.4-nightly.20261027.3796", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + omittedReleaseCount: 0, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; + return { + saved, + bridge: { + setUpdateChannel: async (channel: DesktopUpdateChannel) => { + saved.push(channel); + if (offer instanceof Error) throw offer; + return { + ...base, + channel, + status: offer === null ? ("up-to-date" as const) : ("available" as const), + availableVersion: offer, + }; + }, + }, + }; +} + +const prepare = (bridge: ReturnType["bridge"], reinstall = true) => + prepareLocalTrackSwitch(bridge, { channel: "latest", version: STABLE, reinstall }); + +describe("prepareLocalTrackSwitch", () => { + it("installs the checked release or a newer one on the track, never an older one", async () => { + expect(await prepare(fakeBridge(STABLE).bridge)).toMatchObject({ ok: true }); + // A newer Stable knows every migration the checked one does. + expect(await prepare(fakeBridge("0.1.4").bridge)).toMatchObject({ ok: true }); + + const stale = fakeBridge("0.1.2"); + expect(await prepare(stale.bridge)).toEqual({ + ok: false, + message: "This app found 0.1.2, which is older than 0.1.3. Try again in a few minutes.", + }); + // The saved track is intent; "Switch them" retries without re-picking it. + expect(stale.saved).toEqual(["latest"]); + }); + + it("reports a track that could not be saved", async () => { + expect(await prepare(fakeBridge(new Error("feed check failed")).bridge)).toEqual({ + ok: false, + message: "feed check failed", + }); + }); + + it("only saves the track when this app is already on it", async () => { + const undo = fakeBridge(null); + expect(await prepare(undo.bridge, false)).toEqual({ ok: true, local: null }); + expect(undo.saved).toEqual(["latest"]); + }); +}); diff --git a/apps/web/src/components/settings/updateTrackSwitch.logic.ts b/apps/web/src/components/settings/updateTrackSwitch.logic.ts new file mode 100644 index 000000000000..6e7e49898df0 --- /dev/null +++ b/apps/web/src/components/settings/updateTrackSwitch.logic.ts @@ -0,0 +1,50 @@ +import type { DesktopBridge, DesktopUpdateChannel, DesktopUpdateState } from "@t3tools/contracts"; +import { acceptsTrackSwitchRelease } from "@t3tools/shared/cliRelease"; + +type TrackSwitchBridge = Pick; + +/** Saves the track, then, when this app must reinstall, finds a build the database check covers. + * The saved track is the user's intent and stays even when no such build is ready yet: a track + * saved toward Stable never offers an older build outside a guarded switch, and "Switch them" + * retries. Saving when nothing reinstalls lets picking the old track undo a partial switch. */ +export async function prepareLocalTrackSwitch( + bridge: TrackSwitchBridge, + input: { + readonly channel: DesktopUpdateChannel; + readonly version: string; + readonly reinstall: boolean; + }, +): Promise< + | { readonly ok: true; readonly local: DesktopUpdateState | null } + | { readonly ok: false; readonly message: string } +> { + let local: DesktopUpdateState; + try { + // A build on another track re-checks the feed, older releases allowed down to the checked one. + local = await bridge.setUpdateChannel( + input.channel, + input.reinstall ? input.version : undefined, + ); + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : "The update track did not change.", + }; + } + if (!input.reinstall) return { ok: true, local: null }; + const found = local.downloadedVersion ?? local.availableVersion; + if ( + (local.status === "available" || local.status === "downloaded") && + found !== null && + acceptsTrackSwitchRelease(found, input.channel, input.version) + ) { + return { ok: true, local }; + } + return { + ok: false, + message: + found !== null + ? `This app found ${found}, which is older than ${input.version}. Try again in a few minutes.` + : (local.message ?? `This app found no build of ${input.version} to install.`), + }; +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ec161e999854..e233a4144d43 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -223,19 +223,15 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { /> )} + ); }); -export const SidebarChromeFooter = memo(function SidebarChromeFooter({ - showUndoNotice = false, -}: { - showUndoNotice?: boolean; -}) { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - {showUndoNotice ? : null} diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 30a6776837ee..104bda5582b4 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,7 +1,7 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { type ReactNode } from "react"; +import { type ReactNode, useLayoutEffect } from "react"; import { cn } from "~/lib/utils"; import { animateSidebarLayoutChanges, @@ -9,19 +9,25 @@ import { type SidebarListMarker, } from "../Sidebar.logic"; import { SIDEBAR_SORTABLE_DRAG_DISABLED } from "./SidebarThreadRow"; -import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; function SortableSidebarMarker(props: { marker: SidebarListMarker; className?: string; children?: ReactNode; + revealOverflow?: boolean; "data-testid"?: string; }) { - const { setNodeRef, transform, transition } = useSortable({ + const { node, setNodeRef, transform, transition } = useSortable({ id: sidebarMarkerId(props.marker), disabled: SIDEBAR_SORTABLE_DRAG_DISABLED, animateLayoutChanges: animateSidebarLayoutChanges, }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the projected drop box inside the scroll viewport as drag transforms move it + useLayoutEffect(() => { + if (props.revealOverflow && transform?.y !== undefined) { + node.current?.firstElementChild?.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }, [node, props.revealOverflow, transform?.y]); return (
  • {props.showHint ? (
    void }; - /** The first shelf at the bottom of the list hosts the undo notice just above itself. */ - hostsUndoNotice?: boolean; }) { const shelf = props.marker === "working-header" @@ -124,9 +129,6 @@ export function SidebarSectionHeader(props: { data-testid={`sidebar-${props.marker}`} className={cn("relative mx-0.5 h-8", props.className)} > - {props.hostsUndoNotice ? ( - - ) : null} state.notice); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const summary = notice ? threadUndoNoticeSummary(notice) : null; + // Short enough to fit beside the utility icons; screen readers hear the full summary. + const summary = notice + ? notice.count === 1 + ? notice.action + : `${notice.action} ${notice.count}` + : null; // Keeps the last text on screen while it fades out, then unmounts on transitionend. const [shownSummary, setShownSummary] = useState(summary); @@ -23,21 +29,23 @@ export function SidebarThreadUndoNotice({ className }: { className?: string }) { if (!text) return null; return ( -
    { - if (!summary && event.target === event.currentTarget) setShownSummary(null); - }} - className={cn( - "flex gap-1 px-2 text-2xs leading-6 text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", - !summary && "opacity-0", - className, - )} - > - {text}, - {threadUndoLabel(keybindings)} -
    + // No min-w-0: the undo button sets this item's minimum, so a shared row squeezes its neighbour. + +
    { + if (!summary && event.target === event.currentTarget) setShownSummary(null); + }} + // Reversed wrap: a summary that does not fit beside the undo button drops to a + // second line that overflow hides, instead of truncating mid-word. Browse mode + // reads the button first; the live announcement carries the full sentence. + className={cn( + "flex h-6 min-w-0 flex-1 flex-row-reverse flex-wrap gap-x-1 overflow-hidden text-2xs leading-6 whitespace-nowrap text-sidebar-muted-foreground transition-opacity duration-200 ease-out starting:opacity-0", + !summary && "opacity-0", + )} + > + {threadUndoLabel(keybindings)} + {text}, +
    +
    ); } diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index 714d1564bac0..18ebd70c6162 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -81,7 +81,7 @@ function publish(update: DesktopUpdateState | null) { }); } -const releaseNotes = () => document.querySelector('[aria-label="Nightly update release notes"]'); +const releaseNotes = () => document.querySelector('[aria-label="Update release notes"]'); describe("SidebarUpdatePill release notes", () => { it("closes when the release notes stop applying", async () => { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index ecfeb4d664a3..7e0ee498cece 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -53,7 +53,7 @@ function shouldUseSidebarUpdateReleaseNotesPopover( showUpdateDetails: boolean, state: DesktopUpdateState | null, ): boolean { - return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; + return showUpdateDetails && (state?.releaseNotes.length ?? 0) > 0; } function handleSidebarUpdateReleaseNotesPopoverOpenChange( @@ -134,7 +134,7 @@ const toastUpdateError: ReportLocalUpdateError = (title, description) => { toastManager.add(stackedThreadToast({ type: "error", title, description })); }; -async function downloadLocalUpdate( +export async function downloadLocalUpdate( bridge: DesktopBridge, { notifyDownloaded, @@ -160,12 +160,13 @@ async function downloadLocalUpdate( } } -async function installLocalUpdate( +export async function installLocalUpdate( bridge: DesktopBridge, reportError: ReportLocalUpdateError = toastUpdateError, + expectedVersion?: string, ): Promise { try { - const result = await bridge.installUpdate(); + const result = await bridge.installUpdate(expectedVersion); if (!result.accepted) { showDesktopUpdateDownloadedToast(bridge, result.state); return false; @@ -541,7 +542,7 @@ function SidebarUpdateControl() { {showReleaseNotesPopover && state ? ( { if ( diff --git a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx index b9f29741bfa6..974f81c8d76b 100644 --- a/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdateReleaseNotes.tsx @@ -5,8 +5,8 @@ import { getDesktopUpdateReleaseHistoryUrl, getDesktopUpdateReleaseUrl, } from "../desktopUpdate.logic"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { openDesktopUpdateReleaseNotes } from "../desktopUpdate.toast"; -import { Separator } from "../ui/separator"; type DesktopUpdateShell = Pick; @@ -19,6 +19,18 @@ function keyReleaseNoteItems(items: ReadonlyArray) { }); } +/** Feed times read "3h ago"; build days from a prerelease version read "today" or "2d ago". */ +function formatReleaseAge(releasedAt: string): string { + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(releasedAt); + if (!day) return formatRelativeTimeLabel(releasedAt); + const now = new Date(); + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const days = Math.round( + (today - Date.UTC(Number(day[1]), Number(day[2]) - 1, Number(day[3]))) / 86_400_000, + ); + return days <= 0 ? "today" : `${days}d ago`; +} + function ReleaseLink({ children, releaseUrl, @@ -30,7 +42,7 @@ function ReleaseLink({ }) { return ( { event.preventDefault(); @@ -38,7 +50,7 @@ function ReleaseLink({ }} > {children} - + ); } @@ -52,12 +64,15 @@ export function SidebarUpdateReleaseNotes({ readonly state: DesktopUpdateState; readonly tooltip: string; }) { - if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + if (state.releaseNotes.length === 0) { return <>{tooltip}; } + const releasedAt = state.releaseNotes.find( + (releaseNote) => releaseNote.version === state.availableVersion, + )?.releasedAt; return ( -
    +
    {state.status === "available" ? (
    @@ -66,7 +81,9 @@ export function SidebarUpdateReleaseNotes({
    {state.availableVersion ? (
    - {state.availableVersion} + {releasedAt + ? `${state.availableVersion} ยท ${formatReleaseAge(releasedAt)}` + : state.availableVersion}
    ) : null}
    @@ -74,18 +91,40 @@ export function SidebarUpdateReleaseNotes({
    {tooltip}
    )}
    -
    +
    {state.releaseNotes.map((releaseNote, index) => { const releaseUrl = getDesktopUpdateReleaseUrl(releaseNote.version); const omittedItemCount = Math.max(0, releaseNote.totalItems - releaseNote.items.length); const linkLabel = - omittedItemCount === 0 + releaseNote.stats ?? + (omittedItemCount === 0 ? "View release on GitHub" - : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`; + : `${omittedItemCount} more ${omittedItemCount === 1 ? "change" : "changes"} on GitHub`); + + if (releaseNote.intro !== undefined) { + return ( +
    0 ? "mt-4" : undefined} key={releaseNote.version}> + {releaseNote.version !== state.availableVersion ? ( +

    + {releaseNote.releasedAt + ? `${releaseNote.version} ยท ${formatReleaseAge(releaseNote.releasedAt)}` + : releaseNote.version} +

    + ) : null} +

    + {releaseNote.intro} +

    + {releaseUrl ? ( + + {releaseNote.stats ?? "View release on GitHub"} + + ) : null} +
    + ); + } return ( -
    - {index > 0 && } +
    0 ? "mt-4" : undefined} key={releaseNote.version}>

    {index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`} @@ -107,8 +146,7 @@ export function SidebarUpdateReleaseNotes({ ); })} {state.omittedReleaseCount > 0 ? ( -
    - +
    {`${state.omittedReleaseCount} older ${state.omittedReleaseCount === 1 ? "release" : "releases"} on GitHub`} diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index 8cd5c5ca777d..9bf101b6fa4e 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -47,6 +47,14 @@ interface UpdateEverythingDialogProps { readonly rows: ReadonlyArray; readonly restarts: ReadonlyArray; readonly onConfirm: () => void; + /** Overrides the "Update N machines?" title, for an update-track switch. */ + readonly title?: string; + readonly confirmLabel?: string; + readonly notes?: ReadonlyArray; + /** Shown in place of running-thread counts; disables the confirm button. */ + readonly disabledReason?: string | null; + /** The confirmed work is starting; it cannot be cancelled from here. */ + readonly busy?: boolean; } type UpdateEverythingDialogContentProps = Omit< @@ -89,6 +97,11 @@ function UpdateEverythingDialogContent({ restarts, onConfirm, cancelRef, + title, + confirmLabel = "Update", + notes = [], + disabledReason = null, + busy = false, }: UpdateEverythingDialogContentProps) { const stopped = useAtomValue( runningThreadCountAtom( @@ -116,7 +129,7 @@ function UpdateEverythingDialogContent({ <> - Update {updating.length} {updating.length === 1 ? "machine" : "machines"}? + {title ?? `Update ${updating.length} ${updating.length === 1 ? "machine" : "machines"}?`} {version !== null ? ( @@ -132,6 +145,11 @@ function UpdateEverythingDialogContent({
    + {notes.map((note) => ( +

    + {note} +

    + ))} {/* Machine names truncate first. Provider lists wrap between names, never inside one. */}
    @@ -196,7 +214,11 @@ function UpdateEverythingDialogContent({
    - {running > 0 ? ( + {disabledReason !== null ? ( +

    + {disabledReason} +

    + ) : running > 0 ? (

    {stopped === 0 ? `${threads} will be interrupted and can resume after the restart.` @@ -205,10 +227,12 @@ function UpdateEverythingDialogContent({ : `${threads} will be interrupted. ${continuing} can resume after the restart.`}

    ) : null} - }> + }> Cancel - +
    ); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.test.ts b/apps/web/src/components/sidebar/updateEverything.logic.test.ts index 3739308f7b1c..62aef93572c4 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.test.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.test.ts @@ -2,8 +2,11 @@ import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contrac import { describe, expect, it } from "vite-plus/test"; import { + collectChannelSwitchTargets, collectRestartingMachines, + localSwitchBlockReason, collectServerUpdateTargets, + describeUpdateCapsule, describeUpdateEverything, runUpdateEverything, type UpdateEverythingMachine, @@ -27,7 +30,9 @@ function machine( capabilities: { repositoryIdentity: true, ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}), - ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}), + ...(selfUpdate === "desktop-managed" + ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } + : {}), }, }, settings: { continueThreadsAfterServerUpdate: false }, @@ -64,6 +69,7 @@ describe("collectServerUpdateTargets", () => { machine("manual", "0.0.40", undefined), ], "0.0.50", + true, ); expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ @@ -79,6 +85,154 @@ describe("collectServerUpdateTargets", () => { ["manual", "needs a manual update"], ]); }); + + it("waits for npm only for hosts that install from it or when the release may lack archives", () => { + const hosts = [ + machine("archive-service", "0.0.41-nightly.20260914.1700", "boot-service"), + machine("npm-service", "0.0.41-nightly.20260914.1699", "boot-service"), + machine("legacy-respawn", "0.1.4-nightly.20261027.3796", "respawn"), + machine("desktop", "0.1.4-nightly.20261027.3796", "desktop-managed"), + ]; + const waits = (releaseArchivesPublished: boolean) => + collectServerUpdateTargets( + hosts, + "0.1.5-nightly.20261029.3873", + releaseArchivesPublished, + ).waitForNpm.map((target) => target.serverLabel); + + expect(waits(true)).toEqual(["npm-service", "legacy-respawn"]); + expect(waits(false)).toEqual(["archive-service", "npm-service", "legacy-respawn"]); + }); +}); + +describe("collectChannelSwitchTargets", () => { + const NIGHTLY = "0.1.4-nightly.20261027.3796"; + const OLD_NIGHTLY = "0.1.3-nightly.20261001.3500"; + const NEWER_NIGHTLY = "0.1.4-nightly.20261020.3700"; + // What each server reports as its database's highest migration. + const host = ( + base: UpdateEverythingMachine, + databaseMigration: number | undefined, + os: "linux" | "darwin" = "linux", + ): UpdateEverythingMachine => ({ + ...base, + serverConfig: { + ...base.serverConfig!, + environment: { + ...base.serverConfig!.environment, + platform: { os, arch: "x64" }, + ...(databaseMigration === undefined ? {} : { databaseMigration }), + }, + }, + }); + + it("moves nightly hosts to stable only when stable knows their database", () => { + const stable = { channel: "latest" as const, version: "0.1.3", databaseMigration: 69 }; + const { targets, skipped } = collectChannelSwitchTargets( + [ + host(machine("this-app", NIGHTLY, "desktop-managed", { hostedByThisApp: true }), 72), + host(machine("old-service", OLD_NIGHTLY, "boot-service"), 69), + // Service launchers refuse older versions, even when the database allows it. + host(machine("newer-service", NEWER_NIGHTLY, "boot-service"), 69), + host(machine("linux-desktop", OLD_NIGHTLY, "desktop-managed"), 69), + host(machine("new-service", NIGHTLY, "boot-service"), 72), + host(machine("old-server", "0.1.2-nightly.20260901.3000", "boot-service"), undefined), + host(machine("mac-desktop", OLD_NIGHTLY, "desktop-managed"), 69, "darwin"), + host(machine("preview", "0.1.3-preview.20261026.3702", "boot-service"), 69), + host(machine("stable", "0.1.3", "boot-service"), 69), + host(machine("stable-desktop", "0.1.2", "desktop-managed"), 68), + // Already current, but its saved track may still say Nightly. + host(machine("current-desktop", "0.1.3", "desktop-managed"), 69, "darwin"), + ], + stable, + ); + + expect( + targets.map((target) => [ + target.serverLabel, + target.targetVersion, + target.channel, + target.trackOnly ?? false, + ]), + ).toEqual([ + ["old-service", "0.1.3", undefined, false], + ["linux-desktop", "0.1.3", "latest", false], + ["stable-desktop", "0.1.3", "latest", false], + ["current-desktop", "0.1.3", "latest", true], + ]); + expect(targets.every((target) => target.targetDatabaseMigration === 69)).toBe(true); + expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ + ["newer-service", "run `t3 update --channel stable --allow-downgrade` there"], + [ + "new-service", + "its database has changes 0.1.3 does not support yet; stay on Nightly until Stable has them", + ], + ["old-server", "its server predates database checks; update it on Nightly first"], + ["mac-desktop", "switch the Mac app on that Mac"], + ["preview", "runs a preview build"], + ]); + }); + + it("moves stable hosts to nightly only when nightly knows their database", () => { + const nightly = { channel: "nightly" as const, version: NIGHTLY, databaseMigration: 72 }; + const { targets, skipped } = collectChannelSwitchTargets( + [ + host(machine("service", "0.1.2", "boot-service"), 70), + // A Stable tag built a migration the latest Nightly does not have yet. + host(machine("ahead", "0.1.3", "boot-service"), 73), + machine("old-desktop", "0.1.2", "desktop-managed", { + serverConfig: { + ...machine("old-desktop", "0.1.2", "desktop-managed").serverConfig!, + environment: { + ...machine("old-desktop", "0.1.2", "desktop-managed").serverConfig!.environment, + capabilities: { repositoryIdentity: true, serverSelfUpdate: "desktop-managed" }, + }, + }, + }), + machine("current", NIGHTLY, "boot-service"), + ], + nightly, + ); + + expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([ + ["service", NIGHTLY], + ]); + expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([ + [ + "ahead", + `its database has changes ${NIGHTLY} does not support yet; stay on Stable until Nightly has them`, + ], + ["old-desktop", "needs a newer desktop app"], + ]); + }); +}); + +describe("localSwitchBlockReason", () => { + it("checks every server this app hosts, and only while connected", () => { + const stable = { channel: "latest" as const, version: "0.1.3", databaseMigration: 69 }; + const nightly = "0.1.4-nightly.20261027.3796"; + + const windows = { connected: true, databaseMigration: 69 }; + expect(localSwitchBlockReason(nightly, stable, [windows], 1)).toBeNull(); + // A WSL server's newer migration blocks the reinstall that would restart it. + expect( + localSwitchBlockReason( + nightly, + stable, + [windows, { connected: true, databaseMigration: 72 }], + 2, + ), + ).toContain("does not support yet"); + // A WSL backend still starting has no client entry, so its database is unchecked. + expect(localSwitchBlockReason(nightly, stable, [windows], 2)).toBe( + "its server is not connected, so its database cannot be checked", + ); + // A cached head from a disconnected server proves nothing. + expect( + localSwitchBlockReason(nightly, stable, [{ connected: false, databaseMigration: 69 }], 1), + ).toBe("its server is not connected, so its database cannot be checked"); + expect(localSwitchBlockReason("0.1.3", stable, [], 1)).toBeNull(); + }); }); describe("collectRestartingMachines", () => { @@ -177,3 +331,33 @@ describe("describeUpdateEverything", () => { ]); }); }); + +describe("describeUpdateCapsule", () => { + it("sends failed track switches back through the planner instead of retrying their target", () => { + const failed = { + machine: { + environmentId: EnvironmentId.make("remote"), + label: "remote", + updatesProviders: false, + server: { + environmentId: EnvironmentId.make("remote"), + serverLabel: "remote", + selfUpdate: "boot-service" as const, + targetVersion: "0.1.3", + }, + handedOffVersion: null, + startedAt: 0, + result: "failed" as const, + failure: "offline", + providerFailure: null, + }, + status: "failed" as const, + }; + const run = { localVersion: null, phase: "servers" as const, restartAt: null, machines: [] }; + + expect(describeUpdateCapsule(run, [failed], null, 0).action).toBe("retry"); + expect(describeUpdateCapsule({ ...run, trackSwitch: true }, [failed], null, 0).action).toBe( + "dismiss", + ); + }); +}); diff --git a/apps/web/src/components/sidebar/updateEverything.logic.ts b/apps/web/src/components/sidebar/updateEverything.logic.ts index 469e5a0faf67..de21c535f517 100644 --- a/apps/web/src/components/sidebar/updateEverything.logic.ts +++ b/apps/web/src/components/sidebar/updateEverything.logic.ts @@ -1,6 +1,12 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; -import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease"; +import type { + DesktopUpdateChannel, + EnvironmentId, + ServerConfig, + ServerSelfUpdateCapability, +} from "@t3tools/contracts"; +import { cliReleaseChannelOf, desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveServerSelfUpdateCapability, @@ -57,14 +63,25 @@ export interface SkippedMachine { readonly reason: string; } +/** Boot services before this build installed the server from npm, not from release archives. */ +const RELEASE_ARCHIVE_SELF_UPDATE_SINCE = "0.0.41-nightly.20260914.1700"; + /** Skip current and app-hosted servers; the latter update with the app and would relaunch mid-run. - * Desktop-managed servers use their own feed. */ + * Desktop-managed servers use their own feed. `waitForNpm` lists targets that need + * `t3@` on npm first. `releaseArchivesPublished` means the version came from the + * desktop feed: its GitHub Release goes public with the CLI archives that boot services download. */ export function collectServerUpdateTargets( machines: ReadonlyArray, targetVersion: string, -): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } { + releaseArchivesPublished: boolean, +): { + readonly targets: ServerUpdateTarget[]; + readonly skipped: SkippedMachine[]; + readonly waitForNpm: ServerUpdateTarget[]; +} { const targets: ServerUpdateTarget[] = []; const skipped: SkippedMachine[] = []; + const waitForNpm: ServerUpdateTarget[] = []; for (const machine of machines) { const config = machine.serverConfig; if (machine.hostedByThisApp) continue; @@ -76,16 +93,9 @@ export function collectServerUpdateTargets( } const skip = (reason: string) => skipped.push({ environmentId: machine.environmentId, label: machine.label, reason }); - if (config === null || !machine.connected) { - skip("not connected"); - continue; - } - if (!machine.canMaintain) { - skip("no permission"); - continue; - } - if (machine.updateState.status === "running") { - skip("already updating"); + const unavailable = unavailableReason(machine); + if (config === null || unavailable !== null) { + skip(unavailable ?? "not connected"); continue; } const selfUpdate = resolveServerSelfUpdateCapability(config); @@ -106,18 +116,213 @@ export function collectServerUpdateTargets( skip(`on the ${cliReleaseChannelOf(serverVersion)} channel`); continue; } - targets.push({ + const target: ServerUpdateTarget = { environmentId: machine.environmentId, serverLabel: machine.label, selfUpdate, installation: config.environment.capabilities.serverInstallation, desktopAppUpdate, targetVersion, + }; + targets.push(target); + if ( + selfUpdate !== "desktop-managed" && + (!releaseArchivesPublished || + selfUpdate === "respawn" || + compareSemverVersions(serverVersion, RELEASE_ARCHIVE_SELF_UPDATE_SINCE) < 0) + ) { + waitForNpm.push(target); + } + } + return { targets, skipped, waitForNpm }; +} + +function unavailableReason(machine: UpdateEverythingMachine): string | null { + if (machine.serverConfig === null || !machine.connected) return "not connected"; + if (!machine.canMaintain) return "no permission"; + if (machine.updateState.status === "running") return "already updating"; + return null; +} + +/** The latest release on the track a fleet switches to, as npm reports it. */ +export interface ChannelSwitchRelease { + readonly channel: DesktopUpdateChannel; + readonly version: string; + readonly databaseMigration: number | undefined; +} + +export const channelSwitchLabel = (channel: DesktopUpdateChannel) => + channel === "nightly" ? "Nightly" : "Stable"; + +/** Stable and Nightly each build their own commits, so either can hold migrations the other lacks. + * Crossing tracks needs proof that the target release knows every migration the database holds. */ +const crossesTrack = (fromVersion: string, release: ChannelSwitchRelease) => + desktopUpdateChannelOf(fromVersion) !== release.channel; + +function channelSwitchBlockReason( + fromVersion: string, + release: ChannelSwitchRelease, + currentMigration: number | undefined, +): string | null { + if (!crossesTrack(fromVersion, release)) return null; + const from = channelSwitchLabel(desktopUpdateChannelOf(fromVersion)); + // Servers and releases from before this check report no migration number. + if (currentMigration === undefined) { + return `its server predates database checks; update it on ${from} first`; + } + if (release.databaseMigration === undefined) { + return `${release.version} predates database checks; wait for a newer ${channelSwitchLabel(release.channel)} release`; + } + return currentMigration > release.databaseMigration + ? `its database has changes ${release.version} does not support yet; stay on ${from} until ${channelSwitchLabel(release.channel)} has them` + : null; +} + +/** This app's reinstall restarts every server it hosts, so each of their databases must allow the + * switch. `backendCount` is the desktop's own inventory: a backend still starting has no client + * entry yet. A cached config can outlive its connection, so only a connected server's head counts. */ +export function localSwitchBlockReason( + appVersion: string, + release: ChannelSwitchRelease, + hosted: ReadonlyArray<{ + readonly connected: boolean; + readonly databaseMigration: number | undefined; + }>, + backendCount: number, +): string | null { + if (!crossesTrack(appVersion, release)) return null; + if ( + hosted.length === 0 || + hosted.length < backendCount || + hosted.some((server) => !server.connected) + ) { + return "its server is not connected, so its database cannot be checked"; + } + return ( + hosted + .map((server) => channelSwitchBlockReason(appVersion, release, server.databaseMigration)) + .find((reason) => reason !== null) ?? null + ); +} + +/** Servers move to the release; desktop-managed hosts reinstall their app on its track. A desktop + * host already current gets a track-only request, since its saved track may still differ. */ +export function collectChannelSwitchTargets( + machines: ReadonlyArray, + release: ChannelSwitchRelease, +): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } { + const targets: ServerUpdateTarget[] = []; + const skipped: SkippedMachine[] = []; + for (const machine of machines) { + const config = machine.serverConfig; + if (machine.hostedByThisApp) continue; + const skip = (reason: string) => + skipped.push({ environmentId: machine.environmentId, label: machine.label, reason }); + const target = ( + selfUpdate: ServerSelfUpdateCapability, + targetVersion: string, + ): ServerUpdateTarget => ({ + environmentId: machine.environmentId, + serverLabel: machine.label, + selfUpdate, + installation: config?.environment.capabilities.serverInstallation, + desktopAppUpdate: config !== null && supportsDesktopAppUpdate(config), + targetVersion, + ...(selfUpdate === "desktop-managed" ? { channel: release.channel } : {}), + ...(release.databaseMigration === undefined + ? {} + : { targetDatabaseMigration: release.databaseMigration }), }); + const serverVersion = config?.environment.serverVersion; + const sameTrack = serverVersion !== undefined && isOnTrack(serverVersion, release.channel); + if ( + config !== null && + sameTrack && + resolveVersionMismatch(serverVersion, release.version) === null + ) { + const move = trackSwitchMove(config, true); + if ( + !("skip" in move) && + move.selfUpdate === "desktop-managed" && + unavailableReason(machine) === null + ) { + targets.push({ ...target(move.selfUpdate, serverVersion), trackOnly: true }); + } + continue; + } + const unavailable = unavailableReason(machine); + if (config === null || serverVersion === undefined || unavailable !== null) { + skip(unavailable ?? "not connected"); + continue; + } + const move = trackSwitchMove(config, sameTrack); + if ("skip" in move) { + skip(move.skip); + continue; + } + const blocked = channelSwitchBlockReason( + serverVersion, + release, + config.environment.databaseMigration, + ); + if (blocked !== null) { + skip(blocked); + continue; + } + // Service launchers only accept newer versions, so leaving for an older release is manual. + if ( + move.selfUpdate === "boot-service" && + compareSemverVersions(release.version, serverVersion) <= 0 + ) { + skip( + `run \`t3 update --channel ${release.channel === "nightly" ? "nightly" : "stable"} --allow-downgrade\` there`, + ); + continue; + } + targets.push(target(move.selfUpdate, release.version)); } return { targets, skipped }; } +/** A remote host on the other track that a switch can still move, for retrying a partial switch. */ +export function awaitsTrackSwitch( + machine: Pick, + channel: DesktopUpdateChannel, +): boolean { + const config = machine.serverConfig; + return ( + !machine.hostedByThisApp && + config !== null && + !isOnTrack(config.environment.serverVersion, channel) && + !("skip" in trackSwitchMove(config, false)) + ); +} + +function isOnTrack(version: string, channel: DesktopUpdateChannel): boolean { + return cliReleaseChannelOf(version) !== "preview" && desktopUpdateChannelOf(version) === channel; +} + +/** How a switch moves a host, or why it never can from here, whatever the release. */ +function trackSwitchMove( + config: NonNullable, + sameTrack: boolean, +): { readonly selfUpdate: ServerSelfUpdateCapability } | { readonly skip: string } { + if (cliReleaseChannelOf(config.environment.serverVersion) === "preview") { + return { skip: "runs a preview build" }; + } + const selfUpdate = resolveServerSelfUpdateCapability(config); + if (selfUpdate === null) return { skip: "needs a manual update" }; + if (selfUpdate !== "desktop-managed") return { selfUpdate }; + if (config.environment.capabilities.desktopAppChannelSwitch !== true) { + return { skip: "needs a newer desktop app" }; + } + // Squirrel.Mac renames the bundle for the new track and then cannot relaunch it. + if (!sameTrack && config.environment.platform.os === "darwin") { + return { skip: "switch the Mac app on that Mac" }; + } + return { selfUpdate }; +} + export interface UpdateEverythingSteps { readonly downloadLocal?: () => Promise; readonly updateProviders: () => Promise; @@ -163,6 +368,8 @@ export interface UpdateRunMachine { } export interface UpdateEverythingRun { + /** Track switches retry through their planner, which re-checks each database. */ + readonly trackSwitch?: boolean; readonly localVersion: string | null; readonly phase: "providers" | "servers" | "countdown" | "installing"; readonly restartAt: number | null; @@ -265,7 +472,10 @@ export function describeUpdateCapsule( return { tone: "failed", label: failed.length === 1 ? `${failed[0]!.machine.label} failed` : `${failed.length} failed`, - action: failed.some(({ machine }) => machine.server !== null) ? "retry" : "dismiss", + action: + !run.trackSwitch && failed.some(({ machine }) => machine.server !== null) + ? "retry" + : "dismiss", }; } return { tone: "default", label: `${done} of ${machines.length} updated`, action: null }; diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts index af38bbf5d4bc..d8ab3a5bce71 100644 --- a/apps/web/src/components/sidebar/useUpdateEverything.ts +++ b/apps/web/src/components/sidebar/useUpdateEverything.ts @@ -13,7 +13,9 @@ import { getProviderUpdateRunOutcome } from "../ProviderUpdateLaunchNotification import { useProviderUpdates } from "../ProviderUpdatesAction"; import { canUpdateServer, type ServerUpdateOutcome, useServerUpdate } from "../ServerUpdateAction"; import { + type ChannelSwitchRelease, closeFinishedUpdateRun, + collectChannelSwitchTargets, collectRestartingMachines, collectServerUpdateTargets, collectUpdateEverythingResult, @@ -34,9 +36,24 @@ import { const UPDATE_RESTART_DELAY_MS = 5_000; +/** Servers this desktop app runs; they update with it. */ +export function isHostedByThisApp( + environment: ReturnType["environments"][number], +) { + return ( + isElectron && + (environment.entry.target._tag === "PrimaryConnectionTarget" || + isDesktopLocalConnectionTarget(environment.entry.target)) + ); +} + export type ReportLocalUpdateError = (title: string, description: string) => void; -export function useUpdateEverything(localUpdateVersion: string | null) { +/** `channelSwitch` makes the run move the fleet to that release's track instead of updating it. */ +export function useUpdateEverything( + localUpdateVersion: string | null, + channelSwitch?: ChannelSwitchRelease, +) { const targetVersion = localUpdateVersion ?? APP_VERSION; const { environments } = useEnvironments(); const environmentIds = environmentIdsKey(environments.map(({ environmentId }) => environmentId)); @@ -47,23 +64,22 @@ export function useUpdateEverything(localUpdateVersion: string | null) { label: environment.label, serverConfig: environment.serverConfig, connected: environment.connection.phase === "connected", - hostedByThisApp: - isElectron && - (environment.entry.target._tag === "PrimaryConnectionTarget" || - isDesktopLocalConnectionTarget(environment.entry.target)), + hostedByThisApp: isHostedByThisApp(environment), canMaintain: canUpdateServer(sessionStates[index]!), updateState: updateStates[index]!, })); - const { targets: behindServers, skipped: unreachable } = collectServerUpdateTargets( - machines, - targetVersion, - ); - // Desktop-managed servers use their app feed; only npm installs need to wait for publication. - const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed"); - const published = useNpmReleasePublished(needsNpm ? targetVersion : null); + // A track switch's target came from npm already. + const { + targets: behindServers, + skipped: unreachable, + waitForNpm, + } = channelSwitch + ? { ...collectChannelSwitchTargets(machines, channelSwitch), waitForNpm: [] } + : collectServerUpdateTargets(machines, targetVersion, localUpdateVersion !== null); + const published = useNpmReleasePublished(waitForNpm.length > 0 ? targetVersion : null); const servers = published ? behindServers - : behindServers.filter((server) => server.selfUpdate === "desktop-managed"); + : behindServers.filter((server) => !waitForNpm.includes(server)); const skipped: ReadonlyArray = [ ...unreachable, ...behindServers @@ -74,14 +90,18 @@ export function useUpdateEverything(localUpdateVersion: string | null) { reason: `t3@${targetVersion} is not on npm yet`, })), ]; - const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null); - const providers = useProviderUpdates(); + // Track-only hosts save a setting and restart nothing, so they are neither listed nor counted. + const moving = servers.filter((server) => server.trackOnly !== true); + const restarts = collectRestartingMachines(machines, moving, localUpdateVersion !== null); + const providerUpdates = useProviderUpdates(); + // A track switch moves app and servers only; provider updates stay a separate choice. + const providers = channelSwitch ? { machines: [], updateAll: async () => [] } : providerUpdates; const updateServer = useServerUpdate(); const view = describeUpdateEverything({ localVersion: localUpdateVersion, localMachines: machines.filter((machine) => machine.hostedByThisApp), - servers, + servers: moving, skipped, providerMachines: providers.machines.map((machine) => ({ environmentId: machine.environmentId, @@ -138,6 +158,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) { } const localVersion = installLocal ? localUpdateVersion : null; setUpdateEverythingRun(() => ({ + ...(channelSwitch ? { trackSwitch: true } : {}), localVersion, phase: "providers", restartAt: null, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 8cabc0b89438..acbf840860a6 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -76,10 +76,13 @@ describe("buildThreadActionMenuItems", () => { expect(enabled({ ...gone, isSettled: true })).toContain("unsettle"); }); - it("disables archive while the thread is running", () => { - const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( - (item) => item.id === "archive", - ); - expect(archiveItem?.disabled).toBe(true); + it("disables archive and turning into a project while the thread is running", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + isRunning: true, + canTurnIntoProject: true, + }); + expect(items.find((item) => item.id === "archive")?.disabled).toBe(true); + expect(items.find((item) => item.id === "turn-into-project")?.disabled).toBe(true); }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index a19a4d586720..0bdf7c9e1d2a 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -3,8 +3,10 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled" import { stopProvisionedCloudMachineMenuItem } from "../cloud/cloudThreadMenu"; export type ThreadActionMenuId = + | "find" | "new-thread-on-branch" | "filter-by-project" + | "turn-into-project" | "project-settings" | "pin" | "unpin" @@ -71,6 +73,7 @@ export interface ThreadActionMenuState { readonly canOperate: boolean; /** The thread's machine is gone for good; it can still be settled or deleted on this device. */ readonly machineGone: boolean; + readonly canFind?: boolean; readonly branch: string | null; readonly projectFilter: { readonly label: string; @@ -95,11 +98,14 @@ export interface ThreadActionMenuState { readonly snoozePresets: ReadonlyArray; /** Guest links need their own grant, so the caller checks it instead of `canOperate`. */ readonly canShareWithGuest?: boolean; + /** A No project thread with its own folder, on a server that can promote it. */ + readonly canTurnIntoProject?: boolean; } /** Local navigation, read markers, and copying remain available to read-only clients. */ export function threadActionRequiresOperate(action: ThreadActionMenuId): boolean { return ![ + "find", "new-thread-on-branch", "project-settings", "mark-unread", @@ -121,6 +127,9 @@ export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { const items: ReadonlyArray> = [ + ...(state.canFind + ? [{ id: "find" as const, label: "Find in thread", icon: "text-search" }] + : []), ...(state.branch ? [ { @@ -194,6 +203,16 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.canTurnIntoProject + ? [ + { + id: "turn-into-project" as const, + label: "Turn into project", + icon: "folder-plus", + disabled: state.isRunning, + }, + ] + : []), ...(state.supports.autoSettleOptOut ? [ { diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 287c871beb8b..95c459b8f8e8 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -163,7 +163,7 @@ function MenuCheckboxItem({ variant = "default", ...props }: MenuPrimitive.CheckboxItem.Props & { - variant?: "default" | "switch"; + variant?: "default" | "icon" | "switch"; }) { return ( ) : ( <> - + { ).toBe("Map to a different model."); }); }); + +describe("model mapping validation", () => { + const mapping = (model: string, alias: string): UsagePriceDraft => ({ + id: `model:${model}`, + model, + alias, + isNew: false, + values: {}, + }); + + it("rejects a cycle created by several mappings in one save", () => { + const edits = [mapping("a", "b"), mapping("b", "c"), mapping("c", "a")]; + const result = usagePriceTableChanges(target("local", {}), edits); + expect([...result.errors.keys()]).toEqual(["model:a", "model:b", "model:c"]); + }); + + it("checks each environment's saved chain when an edited mapping closes it", () => { + const edits = [mapping("c", "a")]; + const acyclic = target("one", {}, { a: "b" }); + const cyclic = target("two", {}, { a: "b", b: "c" }); + expect(usagePriceTableChanges(acyclic, edits).errors.size).toBe(0); + expect(usagePriceTableErrors([acyclic, cyclic], edits).get("model:c")).toBe( + "Model mappings cannot form a cycle on two.", + ); + }); + + it("validates the final batch so a mapping can be reversed in either row order", () => { + const environment = target("local", {}, { constructor: "__proto__" }); + const clear = mapping("constructor", ""); + const reverse = mapping("__proto__", "constructor"); + for (const edits of [ + [reverse, clear], + [clear, reverse], + ]) { + const result = usagePriceTableChanges(environment, edits); + expect(result.errors.size).toBe(0); + expect(result.changes).toEqual( + edits.map(({ model, alias }) => ({ model, alias: alias || null })), + ); + } + }); +}); diff --git a/apps/web/src/components/usage/usagePriceTable.ts b/apps/web/src/components/usage/usagePriceTable.ts index e6ad3d72da8b..04b667c2ec30 100644 --- a/apps/web/src/components/usage/usagePriceTable.ts +++ b/apps/web/src/components/usage/usagePriceTable.ts @@ -115,6 +115,26 @@ export function usagePriceTableChanges( if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key])) changes.push(parsed); } + // Check the final batch so clearing a mapping and reversing it in one save stays valid. + const aliases = new Map(Object.entries(target.aliases ?? {})); + for (const change of changes) { + if (!("alias" in change)) continue; + if (change.alias === null) aliases.delete(change.model); + else aliases.set(change.model, change.alias); + } + for (const draft of drafts) { + if (draft.removed || errors.has(draft.id)) continue; + let model = draft.model.trim(); + const seen = new Set(); + while (aliases.has(model)) { + if (seen.has(model)) { + errors.set(draft.id, `Model mappings cannot form a cycle on ${target.label}.`); + break; + } + seen.add(model); + model = aliases.get(model)!; + } + } return { changes, errors }; } diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 3d06cb4814a7..423272798345 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -130,6 +130,16 @@ const ICON_PATHS: Record(() => ({ notice: null })); -export function threadUndoNoticeSummary(notice: Pick): string { +function threadUndoNoticeSummary(notice: Pick): string { const noun = notice.action === "Discarded" ? "draft" : "thread"; return `${notice.action} ${notice.count} ${noun}${notice.count === 1 ? "" : "s"}`; } diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index bd4b97ec4e15..1311a7784e9b 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -201,6 +201,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e8b1114ed818..5f69a52fd644 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -44,6 +44,8 @@ import { useThreadRouteTarget } from "./useThreadRouteTarget"; import { readUnloadedThreadCount } from "../state/olderSettledShelf"; import { placeNewChat } from "../cloud/newChatPlacement"; import { draftSessionHasInvestedWork } from "../cloud/draftInvestedWork"; +import { resolveProjectDefaultProviderModelSelection } from "../providerInstances"; +import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -146,7 +148,26 @@ export function createNewThreadHandler(router: AnyRouter) { project?.id ?? null, project, ); - const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; + const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const fallbackResolution = resolveNewThreadModelSelection({ + defaultSelection: + configuredDefaultModelSelection ?? + (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null + ? null + : resolveProjectDefaultProviderModelSelection( + environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + targetServerSettings, + projectRef.projectId, + )), + fallbackSelection: targetServerSettings.fallbackModelSelection ?? null, + providers: environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], + settings: targetServerSettings, + projectId: projectRef.projectId, + now: Date.now(), + }); + const projectDefaultModelSelection = fallbackResolution.fallback + ? fallbackResolution.selection + : configuredDefaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 16bb19829135..50fdba38a9fc 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -29,6 +29,7 @@ export interface ResizableWidthHandlers { /** Persist on drag end to avoid a storage write on every animation frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; + readonly setWidth: (width: number) => void; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; @@ -55,6 +56,16 @@ export function useResizableWidth(options: UseResizableWidthOptions): { const clampedWidth = clamp(widthState.width); + const commitWidth = (nextStorageKey: string, nextWidth: number) => { + // Resizing remains usable for this session when browser storage is unavailable. + setWidthState({ storageKey: nextStorageKey, width: nextWidth }); + try { + setLocalStorageItem(nextStorageKey, nextWidth, WidthSchema); + } catch (error) { + console.error("Could not persist panel width.", error); + } + }; + const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. @@ -72,12 +83,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return nextWidth; }, finish(finalWidth) { - setWidthState({ storageKey: startStorageKey, width: finalWidth }); - try { - setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); - } catch (error) { - console.error("Could not persist panel width.", error); - } + commitWidth(startStorageKey, finalWidth); }, cleanup(committed) { host?.style.removeProperty("transition-property"); @@ -94,6 +100,7 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return { width: clampedWidth, + setWidth: (value) => commitWidth(storageKey, clamp(value)), handlers: { ref: drag.ref, onPointerDown: drag.onPointerDown, diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3ada7032159b..d51de34cee71 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -126,6 +126,7 @@ vi.mock("../cloud/sidebarCloud", () => ({ isEnvironmentMachineGone: () => false, })); vi.mock("./useThreadActions", () => ({ + canTurnThreadIntoProject: () => false, useThreadActions: () => Object.fromEntries( [ @@ -155,11 +156,12 @@ const target = { threadId: ThreadId.make("thread"), }; const position = { x: 10, y: 20 }; -const createMenu = () => +const createMenu = (options: { readonly onFind?: (threadRef: typeof target) => void } = {}) => useThreadActionMenu({ threadRef: target, projectCwd: "/project", onStartRename: () => recordEffect("rename"), + onFind: options.onFind, canShareWithGuest: false, }); @@ -184,4 +186,18 @@ describe("thread menu permissions", () => { expect(state.effects).toEqual([]); }, ); + + it("offers find only to a header that can open it, without task permission", async () => { + createMenu().openMenu(position); + expect(state.show.mock.calls[0]![0].some((item) => item.id === "find")).toBe(false); + state.show.mockResolvedValue("find"); + const onFind = vi.fn(() => recordEffect("find")); + createMenu({ onFind }).openMenu(position); + await state.completed.promise; + const items = state.show.mock.calls[1]![0]; + expect(items[0]).toMatchObject({ id: "find", label: "Find in thread" }); + expect(items[0]?.disabled).not.toBe(true); + expect(state.effects).toEqual(["find"]); + expect(onFind).toHaveBeenCalledExactlyOnceWith(target); + }); }); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 31162b9d54bf..0789348bd75d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -48,7 +48,7 @@ import * as SidebarCloud from "../cloud/sidebarCloud"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; -import { useThreadActions } from "./useThreadActions"; +import { canTurnThreadIntoProject, useThreadActions } from "./useThreadActions"; function failureToast(title: string, error: unknown) { toastManager.add( @@ -64,9 +64,10 @@ export function useThreadActionMenu(input: { readonly threadRef: ScopedThreadRef | null; readonly projectCwd: string | null; readonly onStartRename: () => void; + readonly onFind?: ((threadRef: ScopedThreadRef) => void) | undefined; readonly canShareWithGuest: boolean; }) { - const { threadRef, projectCwd, onStartRename, canShareWithGuest } = input; + const { threadRef, projectCwd, onStartRename, onFind, canShareWithGuest } = input; const router = useRouter(); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -87,6 +88,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, markThreadUnread, + turnThreadIntoProject, } = useThreadActions(); const { stopProvisionedCloudMachine } = SidebarCloud.useCloudThreadActions(); const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { @@ -138,6 +140,7 @@ export function useThreadActionMenu(input: { const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), machineGone, + canFind: onFind !== undefined, branch: thread.branch ?? null, projectFilter: null, isPinned: thread.pinnedAt != null, @@ -151,6 +154,7 @@ export function useThreadActionMenu(input: { supports, snoozePresets, canShareWithGuest, + canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); const clicked = await settlePromise(() => api.contextMenu.show(items, position)); if (clicked._tag === "Failure" || clicked.value === null) return; @@ -193,6 +197,9 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "find": + onFind?.(threadRef); + return; case "project-settings": { const project = projects.find( (candidate) => @@ -265,6 +272,19 @@ export function useThreadActionMenu(input: { case "share-guest": openGuestShareDialog(threadRef); return; + case "turn-into-project": { + const result = await turnThreadIntoProject(threadRef); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Project created", + description: thread.title, + }); + } else if (!isAtomCommandInterrupted(result)) { + failureToast("Could not turn thread into a project", squashAtomCommandFailure(result)); + } + return; + } case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d90dea14fe1b..14022a8b4676 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -29,6 +29,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentServerConfigsAtom } from "../state/server"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { orchestrationEnvironment } from "../state/orchestration"; +import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -230,6 +231,20 @@ export function useAcknowledgeThreadWoke() { }; } +/** A No project thread that already has its own folder, on a server that can promote it. */ +export function canTurnThreadIntoProject(threadRef: ScopedThreadRef): boolean { + const thread = readThreadShell(threadRef); + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(threadRef.environmentId); + if (!thread?.worktreePath || config?.environment.capabilities.threadProjectFromScratch !== true) { + return false; + } + const project = readProject({ + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + return project !== null && isScratchProject(project, config.scratchWorkspaceRoot); +} + function threadOperationFailure(target: ScopedThreadRef) { return readEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope) ? null @@ -282,6 +297,9 @@ export function useThreadActions() { reportFailure: false, }); const markThreadUnread = useMarkThreadUnread(); + const createProjectFromThread = useAtomCommand(projectEnvironment.createFromThread, { + reportFailure: false, + }); const stopThreadSession = useOrchestrationCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -936,5 +954,10 @@ export function useThreadActions() { reorderActiveThread, markThreadUnread, setThreadAutoSettle, + turnThreadIntoProject: (target: ScopedThreadRef) => + createProjectFromThread({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }), }; } diff --git a/apps/web/src/lib/surfaceShortcutKey.ts b/apps/web/src/lib/surfaceShortcutKey.ts new file mode 100644 index 000000000000..98c4d6660da1 --- /dev/null +++ b/apps/web/src/lib/surfaceShortcutKey.ts @@ -0,0 +1,14 @@ +type SurfaceKeyEvent = Pick & + Partial>; + +export function surfaceShortcutKey(event: SurfaceKeyEvent): string | null { + // AltGr text must stay text even when the browser omits Ctrl and Alt. + if (event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key)) return null; + const typed = event.key.toLowerCase(); + // Preserve Latin layouts, punctuation and dead keys; non-Latin letters/marks use the physical key. + const physical = + /^[\p{L}\p{M}]+$/u.test(typed) && !/\p{Script=Latin}/u.test(typed) + ? event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() + : undefined; + return physical ?? typed; +} diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts index 810a04755e89..e841e18ce24d 100644 --- a/apps/web/src/lib/turnDiffTree.ts +++ b/apps/web/src/lib/turnDiffTree.ts @@ -98,20 +98,6 @@ function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { return [...subdirectories, ...files]; } -export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { - return files.reduce( - (acc, file) => { - const stat = readStat(file); - if (!stat) return acc; - return { - additions: acc.additions + stat.additions, - deletions: acc.deletions + stat.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); -} - export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { const root: MutableDirectoryNode = { name: "", diff --git a/apps/web/src/modelPickerVisibility.ts b/apps/web/src/modelPickerVisibility.ts index a8177fe83b5c..17de95380fb8 100644 --- a/apps/web/src/modelPickerVisibility.ts +++ b/apps/web/src/modelPickerVisibility.ts @@ -1,8 +1,13 @@ -const MODEL_PICKER_CONTENT_SELECTOR = "[data-model-picker-content]"; +// Keydown handlers read this on every key, so a mount count replaces a document query. +let mountedModelPickers = 0; + +export function registerModelPickerContent(): () => void { + mountedModelPickers += 1; + return () => { + mountedModelPickers -= 1; + }; +} export function isModelPickerOpen(): boolean { - return ( - typeof document !== "undefined" && - document.querySelector(MODEL_PICKER_CONTENT_SELECTOR) !== null - ); + return mountedModelPickers > 0; } diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 69800be4b757..1178c9104305 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,14 +5,10 @@ import { threadContextReference } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { - buildPendingUserInputAnswers, carryDisplacedCustomAnswerIntoPrompt, derivePendingUserInputProgress, projectPendingUserInputAnswer, - resolvePendingUserInputAnswer, - setPendingUserInputCustomAnswer, threadContextsReferencedByAnswer, - togglePendingUserInputOptionSelection, } from "./pendingUserInput"; describe("projectPendingUserInputAnswer", () => { @@ -68,128 +64,6 @@ const singleSelectQuestion = { multiSelect: false, } as const; -const nativeChoiceQuestion = { - id: "result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, -} as const; - -describe("resolvePendingUserInputAnswer", () => { - it("preserves exact editor text, including a deliberately cleared answer", () => { - const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; - expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( - question.initialAnswer, - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( - " Edited message\n\n", - ); - expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); - expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); - expect( - setPendingUserInputCustomAnswer( - { selectedOptionValues: ["Orchestration-first"] }, - "", - question, - ), - ).toEqual({ customAnswer: "" }); - const drafts = { scope: { customAnswer: "" } }; - expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ - customAnswer: "", - resolvedAnswer: "", - canAdvance: true, - isComplete: true, - }); - }); - - it("prefers a custom answer over selected options", () => { - expect( - resolvePendingUserInputAnswer(singleSelectQuestion, { - selectedOptionValues: ["Orchestration-first"], - customAnswer: "Keep the existing envelope for one release", - }), - ).toBe("Keep the existing envelope for one release"); - }); - - it("does not replace a required choice with a custom answer", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["second"], - customAnswer: "Use another result", - }), - ).toBe("second"); - }); - - it("does not submit labels or unknown values when an option has a value", () => { - expect( - resolvePendingUserInputAnswer(nativeChoiceQuestion, { - selectedOptionValues: ["Result", "unknown"], - }), - ).toBeNull(); - }); -}); - -describe("togglePendingUserInputOptionSelection", () => { - it("selects and removes options with the same label independently", () => { - const question = { ...nativeChoiceQuestion, multiSelect: true }; - const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); - const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); - - expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ - result: [" first\t", "second"], - }); - - const secondSelected = togglePendingUserInputOptionSelection( - question, - bothSelected, - " first\t", - ); - expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ - result: ["second"], - }); - }); -}); - -describe("buildPendingUserInputAnswers", () => { - it("returns a canonical answer map for complete prompts", () => { - expect( - buildPendingUserInputAnswers( - [ - singleSelectQuestion, - { - id: "compat", - header: "Compat", - question: "How strict should compatibility be?", - options: [ - { - label: "Keep current envelope", - description: "Preserve current wire format", - }, - ], - multiSelect: false, - }, - ], - { - scope: { - selectedOptionValues: ["Orchestration-first"], - }, - compat: { - customAnswer: "Keep the current envelope for one release window", - }, - }, - ), - ).toEqual({ - scope: "Orchestration-first", - compat: "Keep the current envelope for one release window", - }); - }); -}); - describe("pending user input question progress", () => { const questions = [ singleSelectQuestion, @@ -207,6 +81,17 @@ describe("pending user input question progress", () => { }, ] as const; + it("counts a deliberately cleared answer as answered", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + const drafts = { scope: { customAnswer: "" } }; + expect(derivePendingUserInputProgress([question], drafts, 0)).toMatchObject({ + customAnswer: "", + resolvedAnswer: "", + canAdvance: true, + isComplete: true, + }); + }); + it("derives the active question and advancement state", () => { expect( derivePendingUserInputProgress( @@ -232,25 +117,6 @@ describe("pending user input question progress", () => { }); }); -it("accepts attachment-only answers after every upload finishes", () => { - const questions = [ - { id: "spec", header: "Spec", question: "Provide a spec", options: [], multiSelect: false }, - ]; - expect(buildPendingUserInputAnswers(questions, { spec: { attachmentCount: 1 } })).toEqual({ - spec: "", - }); - expect( - buildPendingUserInputAnswers(questions, { - spec: { attachmentCount: 1, attachmentsBlocked: true }, - }), - ).toBeNull(); - expect( - buildPendingUserInputAnswers([{ ...questions[0]!, allowCustomAnswer: false }], { - spec: { attachmentCount: 1 }, - }), - ).toBeNull(); -}); - describe("carryDisplacedCustomAnswerIntoPrompt", () => { // regression: #12577 clicking a question option discarded typed text it("keeps the thread draft when nothing was typed into the answer", () => { diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index f8f9d6d52b5b..17998eaf8010 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -1,6 +1,13 @@ import type { ThreadContextRecord, UserInputQuestion } from "@t3tools/contracts"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { + buildPendingUserInputAnswers, + normalizeSelectedOptionValues, + resolvePendingUserInputAnswer, + type PendingUserInputAnswer, + type PendingUserInputDraftAnswer, +} from "@t3tools/client-runtime/state/pending-user-input"; import { collectInlineContextIds } from "./lib/composerContextReferences"; @@ -25,20 +32,13 @@ export function threadContextsReferencedByAnswer( return threadContexts.filter((record) => referencedIds.has(record.contextId)); } -export interface PendingUserInputDraftAnswer { - selectedOptionValues?: string[]; - customAnswer?: string; - attachmentCount?: number; - attachmentsBlocked?: boolean; -} - export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; - selectedOptionValues: string[]; + selectedOptionValues: ReadonlyArray; customAnswer: string; - resolvedAnswer: string | string[] | null; + resolvedAnswer: PendingUserInputAnswer | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; @@ -46,72 +46,6 @@ export interface PendingUserInputProgress { canAdvance: boolean; } -function normalizeDraftAnswer(value: string | undefined): string | null { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeSelectedOptionValues(value: string[] | undefined): string[] { - if (!Array.isArray(value)) { - return []; - } - - // Provider option IDs must stay unchanged, including whitespace. - return Array.from(new Set(value.filter((entry) => typeof entry === "string"))); -} - -export function resolvePendingUserInputAnswer( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, -): string | string[] | null { - if (draft?.attachmentsBlocked) return null; - const customAnswer = - question.allowCustomAnswer === false - ? null - : question.initialAnswer !== undefined - ? (draft?.customAnswer ?? null) - : normalizeDraftAnswer(draft?.customAnswer); - if (customAnswer !== null && customAnswer.length > 0) { - return customAnswer; - } - - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( - (value) => question.options.some((option) => (option.value ?? option.label) === value), - ); - if (question.multiSelect) { - return selectedOptionValues.length > 0 - ? selectedOptionValues - : (customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); - } - - return ( - selectedOptionValues[0] ?? - customAnswer ?? - (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) - ); -} - -export function setPendingUserInputCustomAnswer( - draft: PendingUserInputDraftAnswer | undefined, - customAnswer: string, - question?: Pick, -): PendingUserInputDraftAnswer { - const selectedOptionValues = - question?.initialAnswer !== undefined || customAnswer.trim().length > 0 - ? undefined - : normalizeSelectedOptionValues(draft?.selectedOptionValues); - - return { - customAnswer, - ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), - }; -} - const DISPLACED_ANSWER_SEPARATOR = "\n\n"; export function carryDisplacedCustomAnswerIntoPrompt( @@ -128,48 +62,6 @@ export function carryDisplacedCustomAnswerIntoPrompt( return `${prompt.trimEnd()}${DISPLACED_ANSWER_SEPARATOR}${displaced}`; } -export function togglePendingUserInputOptionSelection( - question: UserInputQuestion, - draft: PendingUserInputDraftAnswer | undefined, - optionValue: string, -): PendingUserInputDraftAnswer { - if (question.multiSelect) { - const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); - const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) - ? selectedOptionValues.filter((value) => value !== optionValue) - : [...selectedOptionValues, optionValue]; - - return { - customAnswer: "", - ...(nextSelectedOptionValues.length > 0 - ? { selectedOptionValues: nextSelectedOptionValues } - : {}), - }; - } - - return { - customAnswer: "", - selectedOptionValues: [optionValue], - }; -} - -export function buildPendingUserInputAnswers( - questions: ReadonlyArray, - draftAnswers: Record, -): Record | null { - const answers: Record = {}; - - for (const question of questions) { - const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); - if (answer === null) { - return null; - } - answers[question.id] = answer; - } - - return answers; -} - function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray, draftAnswers: Record, @@ -203,7 +95,9 @@ export function derivePendingUserInputProgress( questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, - selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), + selectedOptionValues: activeQuestion + ? normalizeSelectedOptionValues(activeQuestion, activeDraft?.selectedOptionValues) + : [], customAnswer, resolvedAnswer, usingCustomAnswer: diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index a3364df9a15d..f9e711c40e7f 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -240,3 +240,19 @@ export function resolveDefaultProviderModelSelection( const model = getDefaultProviderInstanceModel(providers, instanceId); return model ? { instanceId, model } : null; } + +export function resolveProjectDefaultProviderModelSelection( + providers: ReadonlyArray, + settings: ServerSettings, + projectId: ProjectId | null, +): ModelSelection | null { + const entries = providerInstanceEntriesFor(providers, settings, projectId); + // Matches resolveComposerProviderSelection: the first driver group wins before any other. + const entry = + resolveSelectableProviderInstanceEntry( + entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), + undefined, + ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); + const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + return entry && model ? { instanceId: entry.instanceId, model } : null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 22f7ddfe2eeb..c9d96ad5f0f6 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ChatSettingsThreadsRouteImport } from './routes/_chat.settings import { Route as ChatSettingsStorageRouteImport } from './routes/_chat.settings.storage' import { Route as ChatSettingsSourceControlRouteImport } from './routes/_chat.settings.source-control' import { Route as ChatSettingsSnapShotRouteImport } from './routes/_chat.settings.snap-shot' +import { Route as ChatSettingsSecretsRouteImport } from './routes/_chat.settings.secrets' import { Route as ChatSettingsScheduledTasksRouteImport } from './routes/_chat.settings.scheduled-tasks' import { Route as ChatSettingsProvidersRouteImport } from './routes/_chat.settings.providers' import { Route as ChatSettingsProjectsRouteImport } from './routes/_chat.settings.projects' @@ -135,6 +136,11 @@ const ChatSettingsSnapShotRoute = ChatSettingsSnapShotRouteImport.update({ path: '/snap-shot', getParentRoute: () => ChatSettingsRoute, } as any) +const ChatSettingsSecretsRoute = ChatSettingsSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => ChatSettingsRoute, +} as any) const ChatSettingsScheduledTasksRoute = ChatSettingsScheduledTasksRouteImport.update({ id: '/scheduled-tasks', @@ -290,6 +296,7 @@ export interface FileRoutesByFullPath { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -331,6 +338,7 @@ export interface FileRoutesByTo { '/settings/projects': typeof ChatSettingsProjectsRoute '/settings/providers': typeof ChatSettingsProvidersRoute '/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/settings/secrets': typeof ChatSettingsSecretsRoute '/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/settings/source-control': typeof ChatSettingsSourceControlRoute '/settings/storage': typeof ChatSettingsStorageRoute @@ -374,6 +382,7 @@ export interface FileRoutesById { '/_chat/settings/projects': typeof ChatSettingsProjectsRoute '/_chat/settings/providers': typeof ChatSettingsProvidersRoute '/_chat/settings/scheduled-tasks': typeof ChatSettingsScheduledTasksRoute + '/_chat/settings/secrets': typeof ChatSettingsSecretsRoute '/_chat/settings/snap-shot': typeof ChatSettingsSnapShotRoute '/_chat/settings/source-control': typeof ChatSettingsSourceControlRoute '/_chat/settings/storage': typeof ChatSettingsStorageRoute @@ -417,6 +426,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -458,6 +468,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/secrets' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -500,6 +511,7 @@ export interface FileRouteTypes { | '/_chat/settings/projects' | '/_chat/settings/providers' | '/_chat/settings/scheduled-tasks' + | '/_chat/settings/secrets' | '/_chat/settings/snap-shot' | '/_chat/settings/source-control' | '/_chat/settings/storage' @@ -638,6 +650,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsSnapShotRouteImport parentRoute: typeof ChatSettingsRoute } + '/_chat/settings/secrets': { + id: '/_chat/settings/secrets' + path: '/secrets' + fullPath: '/settings/secrets' + preLoaderRoute: typeof ChatSettingsSecretsRouteImport + parentRoute: typeof ChatSettingsRoute + } '/_chat/settings/scheduled-tasks': { id: '/_chat/settings/scheduled-tasks' path: '/scheduled-tasks' @@ -824,6 +843,7 @@ interface ChatSettingsRouteChildren { ChatSettingsProjectsRoute: typeof ChatSettingsProjectsRoute ChatSettingsProvidersRoute: typeof ChatSettingsProvidersRoute ChatSettingsScheduledTasksRoute: typeof ChatSettingsScheduledTasksRoute + ChatSettingsSecretsRoute: typeof ChatSettingsSecretsRoute ChatSettingsSnapShotRoute: typeof ChatSettingsSnapShotRoute ChatSettingsSourceControlRoute: typeof ChatSettingsSourceControlRoute ChatSettingsStorageRoute: typeof ChatSettingsStorageRoute @@ -853,6 +873,7 @@ const ChatSettingsRouteChildren: ChatSettingsRouteChildren = { ChatSettingsProjectsRoute: ChatSettingsProjectsRoute, ChatSettingsProvidersRoute: ChatSettingsProvidersRoute, ChatSettingsScheduledTasksRoute: ChatSettingsScheduledTasksRoute, + ChatSettingsSecretsRoute: ChatSettingsSecretsRoute, ChatSettingsSnapShotRoute: ChatSettingsSnapShotRoute, ChatSettingsSourceControlRoute: ChatSettingsSourceControlRoute, ChatSettingsStorageRoute: ChatSettingsStorageRoute, diff --git a/apps/web/src/routes/_chat.settings.secrets.tsx b/apps/web/src/routes/_chat.settings.secrets.tsx new file mode 100644 index 000000000000..d97e00fda19f --- /dev/null +++ b/apps/web/src/routes/_chat.settings.secrets.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { SecretsSettingsPanel } from "../components/settings/SecretsSettings"; +import { isSecretsFeatureEnabled } from "../env"; + +export const Route = createFileRoute("/_chat/settings/secrets")({ + beforeLoad: () => { + if (!isSecretsFeatureEnabled) throw redirect({ to: "/settings/general", replace: true }); + }, + component: SecretsSettingsPanel, +}); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index fdc350fe81ab..7758c9ca5591 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -150,7 +150,7 @@ function SettingsRouteLayout() { return ( { // Send every axis so retain middleware recognizes "all" as an explicit target. void navigate({ diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f905376dc341..df3eb85c3db3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1307,6 +1307,23 @@ describe("HTML renders in the timeline", () => { expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); }); + it("hosts an MCP app whose call failed, as a form-deferring write does", () => { + const mcpApp = { + attachmentId: "render-thread-app-html", + server: "github", + tool: "create_pull_request", + resourceUri: "ui://github-mcp-server/pr-create", + }; + const call = renderCall("failed", { + t3McpApp: mcpApp, + result: { content: [], isError: true }, + }); + const entries = turn( + call.type === "dynamic_tool" ? { ...call, toolName: "github.create_pull_request" } : call, + ); + expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ mcpApp }); + }); + it("keeps a render visible when its superseded attempt folds", () => { const attempt: OrchestrationV2RunAttempt = { id: RunAttemptId.make("attempt-superseded"), @@ -1472,6 +1489,51 @@ describe("incremental v2 timeline entries", () => { expect(next.entries[index]).toBe(entry); }); + it("re-derives only the work entry whose tool item changed", () => { + const fixture = makeStreamingTimelineFixture("Partial"); + const visibleTurnItems = fixture.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, status: "running" as const, completedAt: null } } + : row, + ); + const input = { visibleTurnItems, optimisticMessages: [] }; + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input); + const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems }; + const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous); + expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput)); + for (const [index, entry] of previous.entries.entries()) { + if (entry.id === "live-work") expect(next.entries[index]).not.toBe(entry); + else expect(next.entries[index]).toBe(entry); + } + + const anchoredInput = { + ...nextInput, + anchoredMessages: [ + { + id: MessageId.make("feedback"), + runId: null, + role: "assistant" as const, + text: "Feedback received", + streaming: false, + createdAt: fixture.time(6), + updatedAt: fixture.time(6), + }, + ], + }; + const anchored = deriveTimelineEntriesFromVisibleTurnItemsWithState(anchoredInput); + const movedInput = { + ...anchoredInput, + visibleTurnItems: anchoredInput.visibleTurnItems.map((row) => + row.item.id === "live-work" + ? { ...row, item: { ...row.item, startedAt: DateTime.makeUnsafe(fixture.time(8)) } } + : row, + ), + }; + expect( + deriveTimelineEntriesFromVisibleTurnItemsWithState(movedInput, anchored).entries, + ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(movedInput)); + }); + it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)( "rebuilds entries for a %s change instead of retaining stale v2 metadata", (change) => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 37d7ad27be5a..6cf2761d3d6d 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -711,10 +711,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( continue; } - const mcpApp = - item.type === "dynamic_tool" && item.status === "completed" - ? mcpAppFromToolItem(item) - : undefined; + const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; if (mcpApp !== undefined) { entries.push({ id: item.id, @@ -903,6 +900,27 @@ export function isStreamingTurnItemTextUpdate( return Equal.equals(previousMetadata, nextMetadata); } +/** + * A changed tool or work item maps to exactly one entry with the item's id. + * Messages and question answers take part in cross-entry dedupe, and anchored + * messages are placed by createdAt, so those changes rebuild. + */ +function canRederiveInPlace( + input: TimelineEntriesInput, + previousItem: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2ProjectedTurnItem, +): boolean { + return ( + item.item.id === previousItem.item.id && + item.item.type === previousItem.item.type && + item.item.type !== "user_message" && + item.item.type !== "assistant_message" && + item.item.type !== "user_input_request" && + ((input.anchoredMessages?.length ?? 0) === 0 || + projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem)) + ); +} + function reuseTimelineEntries( input: TimelineEntriesInput, previous: TimelineEntriesProjection, @@ -943,15 +961,45 @@ function reuseTimelineEntries( OrchestrationV2ProjectedTurnItem, OrchestrationV2ProjectedTurnItem >(); + const changedItems: OrchestrationV2ProjectedTurnItem[] = []; for (let index = 0; index < before.visibleTurnItems.length; index += 1) { const previousItem = before.visibleTurnItems[index]!; const item = input.visibleTurnItems[index]!; if (item === previousItem) continue; - if (!isStreamingTurnItemTextUpdate(previousItem, item)) return null; - replacements.set(previousItem, item); + if (isStreamingTurnItemTextUpdate(previousItem, item)) { + replacements.set(previousItem, item); + continue; + } + if (!canRederiveInPlace(input, previousItem, item)) return null; + changedItems.push(item); + } + // One batch keeps the attempt/node/plan lookups to a single build. + const rederived = + changedItems.length === 0 + ? [] + : deriveTimelineEntriesFromVisibleTurnItems({ + ...input, + visibleTurnItems: changedItems, + optimisticMessages: [], + anchoredMessages: [], + }); + if ( + rederived.length !== changedItems.length || + rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id) + ) { + return null; } - if (replacements.size === 0 && !appended) return previous.entries; + const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry])); + if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) { + return previous.entries; + } + let rederivedCount = 0; const entries = previous.entries.map((entry): TimelineEntry => { + const rederivedEntry = rederivedEntries.get(entry.id); + if (rederivedEntry !== undefined) { + rederivedCount += 1; + return rederivedEntry; + } const row = entry.kind === "message" && entry.projectedItem !== undefined ? replacements.get(entry.projectedItem) @@ -967,6 +1015,7 @@ function reuseTimelineEntries( }, }; }); + if (rederivedCount !== rederivedEntries.size) return null; if (appended) { entries.push( ...deriveTimelineEntriesFromVisibleTurnItems({ diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index fcd4bb4b8f2e..da19cc7d5a2b 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -171,3 +171,14 @@ export const deviceEasAccountCheck = createEnvironmentRpcQueryAtomFamily(connect staleTimeMs: 0, idleTtlMs: 0, }); + +/** Who a Simlock token is, checked as the host editor's URL and token fields change. */ +export const deviceSimlockIdentityCheck = createEnvironmentRpcQueryAtomFamily( + connectionAtomRuntime, + { + label: "environment-data:device:simlock-identity-check", + tag: WS_METHODS.deviceSimlockIdentity, + staleTimeMs: 0, + idleTtlMs: 0, + }, +); diff --git a/apps/web/src/state/npmRelease.ts b/apps/web/src/state/npmRelease.ts index 4436a42c29e4..09db3b68c069 100644 --- a/apps/web/src/state/npmRelease.ts +++ b/apps/web/src/state/npmRelease.ts @@ -1,31 +1,51 @@ import { useAtomValue } from "@effect/atom-react"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; import * as Effect from "effect/Effect"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; import { AsyncResult, Atom } from "effect/reactivity"; -export const npmReleasePublishedAtom = Atom.family((version: string) => +export interface NpmRelease { + readonly version: string; + /** Absent on releases published before npm manifests carried it. */ + readonly databaseMigration: number | undefined; +} + +/** `spec` is an exact version or a dist-tag such as `nightly`. Null when npm has no such release. + * Refreshes only while mounted; the published check below keeps its own versions alive. */ +export const npmReleaseAtom = Atom.family((spec: string) => Atom.make( Effect.gen(function* () { const response = yield* HttpClient.get( - `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`, + `https://registry.npmjs.org/t3/${encodeURIComponent(spec)}`, ); - if (response.status === 404) return false; + if (response.status === 404) return null; const body = yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.flatMap((ok) => ok.json), ); - return typeof body === "object" && body !== null && "version" in body - ? body.version === version - : false; + if ( + typeof body !== "object" || + body === null || + !("version" in body) || + typeof body.version !== "string" + ) { + return null; + } + return { + version: body.version, + databaseMigration: npmReleaseDatabaseMigration(body), + } satisfies NpmRelease; }).pipe( // Tracing headers would make the browser send a CORS preflight, which npm rejects. Effect.provideService(HttpClient.TracerPropagationEnabled, false), Effect.provide(FetchHttpClient.layer), ), - ).pipe( - Atom.withRefresh("5 minutes"), - Atom.keepAlive, - Atom.withLabel(`npm-release-published:${version}`), - ), + ).pipe(Atom.withRefresh("5 minutes"), Atom.withLabel(`npm-release:${spec}`)), +); + +export const npmReleasePublishedAtom = Atom.family((version: string) => + Atom.make((get) => + get.result(npmReleaseAtom(version)).pipe(Effect.map((release) => release?.version === version)), + ).pipe(Atom.keepAlive, Atom.withLabel(`npm-release-published:${version}`)), ); const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe( diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index 3bd8a89807e4..60c81478f743 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -1067,7 +1067,8 @@ export class GhosttyTerminalCore { } } let wide = 0; - if (text.length === 0 && cells.at(-1)?.text.length) { + // A wrap spacer in the final column can follow a spacer tail. + if (text.length === 0 && (cells.at(-1)?.text.length || cells.length === cols - 1)) { this.assertSuccess( "ghostty_render_state_row_cells_get(raw)", this.runtime.call( diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 8816105599b8..5473e3c4ffa1 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -512,6 +512,42 @@ const cell = (text: string): GhosttyCell => ({ }); describe("terminalLinkAtPositionWithRange", () => { + it.each([ + { text: "https://example.com/ๆ—ฅๆœฌ", cols: 80, end: { x: 23, y: 0 } }, + { text: "src/ๆ—ฅๆœฌ/main.ts:3:1", cols: 80, end: { x: 19, y: 0 } }, + { text: "https://example.com/๐Ÿ™‚", cols: 80, end: { x: 21, y: 0 } }, + { text: "https://example.com/ๆ—ฅๆœฌ", cols: 23, end: { x: 1, y: 1 } }, + ])("keeps every cell of $text clickable at $cols columns", async ({ text, cols, end }) => { + const core = await GhosttyTerminalCore.create( + cols, + 3, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + try { + core.write(text + " next"); + const rows = core.snapshot().rowData; + for (let y = 0; y <= end.y; y += 1) { + const lastColumn = y === end.y ? end.x : cols - 2; + for (let x = 0; x <= lastColumn; x += 1) { + expect(terminalLinkAtPositionWithRange(rows, y, x)).toEqual({ + text, + range: { start: { x: 0, y: 0 }, end }, + }); + } + } + expect(terminalLinkAtPositionWithRange(rows, end.y, end.x + 1)).toBeNull(); + } finally { + core.dispose(); + } + }); + it("activates a wrapped quoted Windows path from its spaces and position suffix", () => { const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({ cells: Array.from(text, (character) => cell(character)), diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index cb05acdc5e03..802d39e59407 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -2,7 +2,9 @@ import { isMacPlatform } from "../../lib/utils"; import { SELECTION_MULTI_CLICK_INTERVAL_MS } from "../../lib/selectionActions"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { + GHOSTTY_CELL_WIDE, GhosttyTerminalCore, + type GhosttyCell, type GhosttyScrollbar, type GhosttySnapshot, type GhosttyTheme, @@ -201,15 +203,23 @@ function terminalGridCellAt(options: { }; } +function terminalCellText(cell: GhosttyCell): string { + if (cell.wide === GHOSTTY_CELL_WIDE.spacerTail || cell.wide === GHOSTTY_CELL_WIDE.spacerHead) { + return ""; + } + return cell.text || " "; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { - const text = row.cells.map((cell) => cell.text || " ").join(""); + const text = row.cells.map(terminalCellText).join(""); return trimRight ? text.trimEnd() : text; } function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { let offset = 0; for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { - offset += row.cells[cellIndex]?.text.length || 1; + const cell = row.cells[cellIndex]; + offset += cell ? terminalCellText(cell).length : 1; } return offset; } @@ -265,7 +275,8 @@ export function terminalLinkAtPositionWithRange( const lastSegment = wrappedLine.segments.at(-1); const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; - const offset = segment.startIndex + terminalColumnOffset(row, column); + const hitColumn = row.cells[column]?.wide === GHOSTTY_CELL_WIDE.spacerTail ? column - 1 : column; + const offset = segment.startIndex + terminalColumnOffset(row, hitColumn); for (const match of extractTerminalLinks(wrappedLine.text)) { if (offset >= match.start && offset < match.end) { if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; @@ -278,6 +289,7 @@ export function terminalLinkAtPositionWithRange( const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; if (!startSegment || !endSegment || !startRow || !endRow) return null; + const endColumn = terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex); return { text: match.text, range: { @@ -286,7 +298,10 @@ export function terminalLinkAtPositionWithRange( y: startSegment.bufferLineNumber - 1, }, end: { - x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + x: + endRow.cells[endColumn + 1]?.wide === GHOSTTY_CELL_WIDE.spacerTail + ? endColumn + 1 + : endColumn, y: endSegment.bufferLineNumber - 1, }, }, diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 57e8836f27ff..1ad8ba86c06a 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -7,6 +7,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore, } from "./terminalUiStateStore"; +import { DEFAULT_THREAD_TERMINAL_ID } from "./types"; const THREAD_ID = ThreadId.make("thread-1"); const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); @@ -138,6 +139,27 @@ describe("terminalUiStateStore actions", () => { ]); }); + it.each([ + ["terminal-3", "terminal-3", DEFAULT_THREAD_TERMINAL_ID], + [DEFAULT_THREAD_TERMINAL_ID, DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ["terminal-3", DEFAULT_THREAD_TERMINAL_ID, "terminal-3"], + ])("keeps the split's tab when %s is active and %s closes", (activeId, closedId, nextId) => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(THREAD_REF, "terminal-2"); + store.setActiveTerminal(THREAD_REF, DEFAULT_THREAD_TERMINAL_ID); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.setActiveTerminal(THREAD_REF, activeId); + store.closeTerminal(THREAD_REF, closedId); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.activeTerminalId).toBe(nextId); + expect(terminalUiState.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + it("does not import a closed panel terminal from stale metadata", () => { const store = useTerminalUiStateStore.getState(); store.newTerminal(THREAD_REF, "term-2"); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6629d8a6d99b..b442e855ca12 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -413,9 +413,15 @@ function closeThreadTerminal( } const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); + const groupTerminalIds = + normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId)) + ?.terminalIds ?? []; + const groupIndex = groupTerminalIds.indexOf(terminalId); const nextActiveTerminalId = normalized.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + ? (groupTerminalIds[groupIndex + 1] ?? + groupTerminalIds[groupIndex - 1] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? remainingTerminalIds[0] ?? "") : normalized.activeTerminalId; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 307b9c5d8284..63f7cca9cbfc 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -15,6 +15,7 @@ interface ImportMetaEnv { readonly VITE_RELAY_OTLP_TRACES_DATASET: string; readonly VITE_RELAY_OTLP_TRACES_TOKEN: string; readonly T3CODE_DESKTOP_UPDATE_REPOSITORY: string; + readonly T3CODE_FEATURE_SECRETS: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 501bac5dba02..da07814b29f6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -49,6 +49,7 @@ const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || process.env.GITHUB_REPOSITORY?.trim() || ""; +const configuredFeatureSecrets = process.env.T3CODE_FEATURE_SECRETS?.trim() || ""; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); if (explicitHostedAppUrl) { @@ -316,6 +317,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.T3CODE_FEATURE_SECRETS": JSON.stringify(configuredFeatureSecrets), "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, diff --git a/docs/internals/devices.md b/docs/internals/devices.md index c50cb504a84e..5273f7942e9e 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -44,6 +44,27 @@ clients never hold it. Agent config files are written per device device before targeting it. Device settings run `simctl` on the simulator's machine, which EAS does not expose, so they are unavailable there. +## Simlock hosts lease through the agent-device front + +A [Simlock host](../../apps/server/src/device/SimlockDeviceHost.ts) (alpha) opens a +device by running T3's own agent-device CLI against the gateway's agent-device +front (`connect proxy`, then `open`), not with Simlock's lease HTTP API: +agent-device cannot adopt a lease it did not allocate. The lease is bound to +that one agent-device session and to the directory `open` ran in, so +`agentEndpoint` hands every thread the same session, a `stateDir` holding its +connection record, and no `--udid`/`--serial` (the gateway refuses selectors). +Installs work only from that directory; `device_open` names it. + +Simlock serves a lease's screen and input to its holder in serve-sim's formats +on both platforms: AVCC envelopes at `/v1/leases//stream` and serve-sim's +input messages at `/v1/leases//input`, both with the agent token. Clients +therefore treat a Simlock Android lease like iOS (`serveSimOnly` in the stream +client), and `upstream` maps serve-sim's `stream.avcc` and `helper/ws` paths to +those routes. Simlock has no MJPEG, screenshot, or host command routes, so the +panel offers only input there. +Leases are renewed while tracked and released at the host's age limit; one a +crashed server left behind ends on its own one TTL later, so there is no ledger. + ## The hub is never exposed serve-sim has a shell-exec route whose token is readable from its own diff --git a/docs/internals/remote.md b/docs/internals/remote.md index d88d2973f4e4..0c328c50317c 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -73,7 +73,7 @@ A [peer link](../../apps/server/src/peer/PeerLinks.ts) lets one environment's agents work in another. The linking environment signs in to the other's `/mcp` as an outside MCP client, with a pairing code from it, and keeps that session's token in its own secret store. The other environment needs nothing new: it -sees one more OAuth client, lists it in Connections, and revokes it there. +sees one more OAuth client, lists it in Sharing & access, and revokes it there. Unlinking on the linking side only forgets the token. - **The Linked environments beta (`linkedEnvironmentsEnabled`) gates both diff --git a/docs/operations/development.md b/docs/operations/development.md index 211041c22a24..3307dbc22b96 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -163,7 +163,9 @@ for the security model. `vp run check:changed` runs CI's fast checks for your branch's changes: format and lint of the changed files, `knip:check` when code or a manifest changes, the generated GraphQL checks, typecheck -of the affected packages and their dependents, and the test files beside the changed files. It maps +of the affected packages and their dependents, and the test files beside the changed files. It also +runs the checks that read other files: the agent context budget when the server is affected, and +React Compiler coverage for only the changed web and mobile files. It maps files to packages with [ci-changes.cjs](../../.github/scripts/ci-changes.cjs), as CI does. It compares against the merge base with `origin/main`, or with a ref you pass. CI still runs the full test suites and the jobs the summary lists, so a local pass does not promise a green CI run. diff --git a/docs/operations/release.md b/docs/operations/release.md index 9bab0de72f5a..aff3bde76ba0 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -52,6 +52,7 @@ Before enabling a workflow, configure your fork's own publication or deployment - Only plain stable `X.Y.Z` releases are marked as the repository's latest release. - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. + - The release job's **Write release notes** step (`scripts/generate-release-notes.ts`) builds the body: a computed header line, a short summary from `gpt-6.1-terra`, and GitHub's generated list folded under `
    `. It needs the `OPENAI_API_KEY` secret. The summary leaves out commits whose scopes are all mobile; the header counts every commit. The desktop update popover, on every channel, shows the highlights paragraph and the header counts as a short note per release, with the offered release's age from the update feed's `releaseDate`. The nightly Discord post shows the part above `
    `. If the step fails or there is nothing to summarize, it writes no file and the release uses GitHub's generated notes as before. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. - Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) in the same job as that target's desktop artifact and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel, for five targets: macOS arm64, Linux x64 and arm64, Windows x64 and arm64. Every archive is smoke-tested on hardware of its own architecture, and every one but the Windows archives is also built and signed there. There is no macOS x64 archive: Node single-executables are unsupported on x64 macOS (the SEA docs list macOS as arm64 only) and the binary segfaults on start; the x64 desktop app is Electron and unaffected. - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The `t3` npm package exists for people who run `npx t3` or `npm install -g t3` themselves, and it downloads this same archive too; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. @@ -440,7 +441,11 @@ release archives, and then its npm launcher, available before users can receive The workflow enforces this ordering: -1. `release` publishes the GitHub Release with the CLI archives and `SHA256SUMS`. +1. `release` publishes the GitHub Release with the CLI archives and `SHA256SUMS`. Every channel + uploads to a draft and publishes only after all assets are attached, so a new version in the + desktop update feed already has its archives. **Update everything** relies on this to update + background-service hosts before npm has the version. Rerunning `release` after publication + replaces assets in a public release, so service updates can fail until the upload finishes. 2. `publish_cli` publishes the `t3@` launcher, which downloads from that release, on every channel. 3. `deploy_web` depends on both before moving the hosted channel to the new client. diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 4394e688a2af..fb198dbcb33c 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -399,7 +399,10 @@ delegated to a linked environment name that environment. Clients open only that environment's thread and show its current title; a link to a thread they do not know renders as a label that opens nothing. Older `t3-thread://v1/` links name no environment: clients resolve them in the message's environment, -then by a unique match in another enabled environment. +then by a unique match in another enabled environment. An outside OAuth client, +one that is not a linked environment, cannot render `t3-thread://` links, so it +gets a web URL on the origin it reached, `//`, +for threads in this environment. List and read results report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the diff --git a/docs/user/devices.md b/docs/user/devices.md index 42c0db4b27af..ce5a7d8d06d0 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -22,7 +22,7 @@ Choose **Float device over chat** in the toolbar to keep watching and tapping th device in a small window while the right panel shows something else; drag the window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. -Turn off the device hub in **Settings โ†’ Devices** to stop the +Turn off **Use devices** in **Settings โ†’ Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -80,12 +80,12 @@ viewer stops streaming and leaves the device available to the agent. Device activity also appears in the thread timeline. Agents drive the device through the `agent-device` command line. T3 -Code installs and starts it only after **Agent device access** is enabled. iOS +Code installs and starts it only after **Agents can use devices** is on. iOS taps build a small test runner on first use, which takes a couple of minutes once per server. Restart an existing agent session after granting access so it receives the device CLI environment. -To keep agents away from simulators, turn off **Agent device access** in +To keep agents away from simulators, turn off **Agents can use devices** in **Settings โ†’ Devices**. This hides the device tools from agents started from then on; your own Device panel is unaffected. @@ -99,12 +99,12 @@ still-image stream and Android cannot show video. ## SSH device hosts In Settings โ†’ Devices, choose the environments that should use -the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with +the host and choose **Add source โ†’ Machine over SSH**. Enter an SSH alias or `user@host`, with an optional identity file and port. These resolve on the environment server, so use the SSH configuration and keys available there. Password prompts are not supported. -**Test connection** checks SSH, Node, npm, and platform tools without installing +**Test connection**, in the host's menu, checks SSH, Node, npm, and platform tools without installing anything, with a result for each selected environment. Targets that resolve to the environmentโ€™s own machine are skipped, since its devices are already local. The first device listing installs pinned device tools on the host. @@ -123,8 +123,45 @@ installation, and connectivity to development servers such as Metro separately. A simulator on another machine cannot reach Metro through your environment's localhost without forwarding or another reachable address. +## Simlock hosts (alpha) + +[Simlock](https://incubator.callstack.com/simlock/) pools iOS simulators and +Android emulators across machines. If you run a Simlock gateway, T3 can lease +devices from it. Simlock is new and unreleased, so this integration is an +alpha: it may change or stop working with a newer Simlock. T3 does not install +or manage Simlock; set up the gateway and its workers yourself. It needs a +gateway and workers built from Simlock's `main` at +[6316e10](https://github.com/callstackincubator/simlock/commit/6316e10) or +later, the first with a lease holder's screen and input. + +Turn on **Settings โ†’ Labs โ†’ Simlock device hosts (alpha)** and **Use devices** +in Settings โ†’ Devices, then choose **Add source โ†’ Simlock pool** with an **agent token** +(`simlock --gateway token create --role agent`). Give each T3 server its own +token: Simlock counts every lease made with one token against the same +requester, and allows one at a time unless the gateway's +`lease.maxPerRequester` is raised to the host's **Devices at once**. The token +leases devices and reaches only its own leases, but commands on a leased device +run on its worker, so treat it like shell access to your workers. T3 sends it +with every request, so use an HTTPS gateway URL, or plain HTTP only over an +encrypted network such as Tailscale. The gateway +and workers need `capabilities.stream.enabled` and `capabilities.input.enabled` +for the live screen and for input. + +The picker lists the models your workers can make. Opening one takes a lease; +closing it with **Return device to the pool**, or removing the host, returns it +wiped. The panel keeps a watched device leased up to the host's lease limit. +You can tap, swipe, type, and press Home, and Back and Recents on Android. +Rotation, appearance, text size, device tools, and screenshots are not offered +for Simlock devices; agents can still do them through `device_open`. On iOS 27 +simulators, typing on your keyboard does nothing; tap the on-screen keyboard +instead. Workers must run the same agent-device +version as T3 (see Device tool updates), and agent-device accepts app installs +for a lease only from the folder it was opened from, which `device_open` +names. A device cannot reach development servers on your machine through +localhost; use an address its worker can reach. + ## Device tool updates -The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. Settings โ†’ Devices โ†’ Check versions reads installed versions without installing tools or starting devices. +The connected T3 server manages the device hub and agent tools on its own machine and configured SSH hosts. Required versions install automatically the next time those tools are used. In Settings โ†’ Devices, select a version and choose **Check versions** to read installed versions without installing tools or starting devices. To receive newer tool versions on a remote environment, update that environment's T3 server. Updating only the browser or mobile app does not update the remote server. An offline host keeps its installed files, but an update needs network access before device support can start; T3 does not fall back to an older version. Reconnect the host and use Retry if installation fails. Existing device and agent-access settings are preserved. diff --git a/docs/user/faq.md b/docs/user/faq.md index bb4713b5f25e..62a5e1a8446d 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -45,7 +45,7 @@ Get the [iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819 ## Why does T3 Connect keep disconnecting? -T3 Connect is our built-in alternative to Tailscale for remote connections. If it keeps dropping, open **Settings > Connections**, find the machine, and select **Details**. The first part marked as failed is the cause. I will not be disappointed if you set up Tailscale as a fallback (I do this personally). [T3 Connect troubleshooting](./t3-connect-troubleshooting.md) +T3 Connect is our built-in alternative to Tailscale for remote connections. If it keeps dropping, open **Settings > Environments**, find the machine, and select **Details**. The first part marked as failed is the cause. I will not be disappointed if you set up Tailscale as a fallback (I do this personally). [T3 Connect troubleshooting](./t3-connect-troubleshooting.md) ## Will I get banned for using my subscription? diff --git a/docs/user/install.md b/docs/user/install.md index 3edf46d80ee3..1e1693d0b32e 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -65,18 +65,41 @@ update it with `git pull` and a rebuild. Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or use a package manager: -| Platform | Install | -| ------------------ | ---------------------------------- | -| Windows | `winget install T3Tools.T3Code` | -| macOS | `brew install --cask t3-code` | -| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | -| Arch Linux | `yay -S t3code-bin` | -| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Platform | Install | +| ------------------ | ------------------------------------------------------------ | +| Windows | `winget install T3Tools.T3Code` | +| macOS | `brew install --cask t3-code` | +| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Arch Linux | `yay -S t3code-bin` | +| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Nix | `nix profile add github:NixOS/nixpkgs/nixos-unstable#t3code` | The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### Nix (nixpkgs) + +T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). +The command above installs from `nixos-unstable` and requires Nix 2.30 or later with +`nix-command` and `flakes` enabled. Launch the desktop app with `t3code-desktop`; +the package also includes the `t3` command. + +On NixOS, you can instead add the package to your system configuration, using +a nixpkgs revision that includes `t3code`: + +```nix +environment.systemPackages = [ pkgs.t3code ]; +``` + +Apply the change with `sudo nixos-rebuild switch`. For Home Manager, add +`pkgs.t3code` to `home.packages` and run `home-manager switch`. + +Update a profile installation with `nix profile upgrade t3code`. For NixOS or +Home Manager, update your configuration's nixpkgs input or channel and rebuild. +Nix manages both the desktop app and the bundled +`t3` command; use Nix to update them instead of the in-app updater or `t3 update`. + ### The `t3` command The desktop app includes the `t3` command-line tool. To run it from any diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 0b1cd2f2c6da..76bbc8266a94 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -68,9 +68,15 @@ Leave the field blank to use the managed runtime, or a compatible executable on ## Models and threads -The model list comes from your Antigravity account and can differ from other -Antigravity apps. A resumed thread keeps its selected model. If access to that -model ends, select another available model before continuing. +The model list comes from your Antigravity account, including Claude and GPT models +when your plan has them. Pick a thinking level in the composer's effort menu; it +shows only the levels the selected model supports. A resumed thread keeps its +selected model. If access to that model ends, select another available model +before continuing. + +Antigravity creates empty `.gemini/skills` and `.agents/skills` folders in the +workspace when a session starts. Git does not track empty folders, so they never +appear in commits. Use Antigravity's native `/plan` command for planning. T3 Code's separate Plan mode is unavailable. Tool approvals follow [Permission modes](./permission-modes.md). diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 09b4a4051a30..a32ae02b8866 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -7,7 +7,7 @@ machine. That machine must stay running and reachable while you work. T3 Connect makes an environment available to your other devices without setting up router forwarding. In the desktop app on the host, open **Settings โ†’ -Connections**, sign in, and enable **T3 Connect** for that environment. +Sharing & access**, sign in, and enable **T3 Connect** for that environment. For a command-line host, run: @@ -298,12 +298,12 @@ off on either machine, agents start nothing over a link between them. A task already running on the other machine still finishes, and if that machine has it off, its result arrives once it is back on. -On the machine you will work from, open **Settings โ†’ Connections โ†’ Linked environments**, +On the machine you will work from, open **Settings โ†’ Environments โ†’ Linked environments**, choose **Link**, and pick the other machine or enter its address. The list holds the machines this app knows and those on your T3 Connect account. If this app is connected to that machine with a session that manages access there, it makes the pairing code itself; otherwise paste one from that machine's -**Settings โ†’ Connections**. From a terminal, use +**Settings โ†’ Sharing & access**. From a terminal, use `t3 environment link
    --pairing-code
    --access `. You can also ask an agent to link a machine by name, such as "delegate this to @@ -317,7 +317,7 @@ and the access, then approve. access, the link's agents can change only threads it started, not that machine's own threads, projects or settings, and they skip its project setup scripts. -- The link shows up in that machine's **Settings โ†’ Connections** like any +- The link shows up in that machine's **Settings โ†’ Sharing & access** like any outside agent. Revoke it there to end it. **Forget** on this machine only removes it here. - Links last 30 days. Link again with a new pairing code to renew one. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 725e06e8e90a..7f468a848479 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -37,6 +37,12 @@ folder, so the files the agent wrote stay until you delete them. Branch, worktre these folders are not Git repositories. This is unavailable when the data directory itself sits inside a Git checkout. +When the work grows into something real, pick **Turn into project** from the +thread's menu. The thread's folder becomes a project where it is, so nothing +moves and the agent keeps working in the same place. Other threads in that folder +move with it. Rename the project in its settings, and use **Initialize Git** to +get branch and diff controls. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` @@ -282,6 +288,11 @@ Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows. +To choose what the thread details card shows, right-click the card or open +**Settings โ†’ Appearance โ†’ Thread details**. Hidden rows keep their keyboard +shortcuts, such as Open in editor and project scripts. **Reset to default** brings +back the standard rows. + ## Snooze until later To change the choices in the snooze menu, open **Settings โ†’ Threads โ†’ Snooze options**. diff --git a/docs/user/updating.md b/docs/user/updating.md index ad5d93b5759a..cfe746c18733 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -22,10 +22,11 @@ restart from the update button when you are ready. A server still restarting finishes on its own, and the button keeps showing it after the desktop app restarts. -Server updates appear only after that version is published to npm. Remote -desktop apps do not wait for npm. The confirmation lists each machine it skips -and why, such as a machine that is offline or needs a manual update. Use -**Settings โ†’ Environments** for those. +Remote desktop apps never wait for npm. When the desktop app offers the update, +background services update right away too. Other servers, including older +background services, update only after that version is published to npm. The +confirmation lists each machine it skips and why, such as a machine that is +offline or needs a manual update. Use **Settings โ†’ Environments** for those. If a desktop app restarts on its old version, T3 Code tells you the update did not apply. It does not install that version again on its own, even when another @@ -35,6 +36,29 @@ The confirmation counts the running threads that the restarts will interrupt, and how many can resume afterwards. See **Continue threads after restarts** below. +## Switch between Stable and Nightly + +In the desktop app, change **Settings โ†’ About โ†’ Update track**. T3 Code shows each +connected machine it will move and each one it skips, then moves them in one +step. Remote servers install the latest release on the new track. Remote desktop +apps reinstall on the new track. This app reinstalls last. Your threads, +settings, and data stay. + +A background service cannot move to an older release remotely. When Stable is +older than its Nightly, run `t3 update --channel stable --allow-downgrade` on +that machine. + +A switch is blocked for a machine whose database already has changes the +target release does not support, or when T3 Code cannot confirm that it does. +The dialog says why: update a machine on its current track first when its +release is too old to check, or wait until the target track includes the +changes. A Mac running the desktop app must switch on that Mac. + +Each machine keeps the new track even if its switch did not finish, but it +moves to that track only through a switch. **Update track** shows how many +machines still run the other track. **Review** opens the same dialog with fresh +checks. + ## Before you update Server updates restart the connection and can interrupt active agents and diff --git a/docs/user/usage.md b/docs/user/usage.md index 290b746eb181..6b6dbe356411 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -44,6 +44,10 @@ Use absolute paths or `~/` paths in the account's environment settings; relative depend on each project's working directory and cannot be reliably discovered by Usage. Accounts sharing a history directory count once. +The server saves its first hostname in `/usage-host-id`, so a recreated container keeps +its usage identity when T3 home is persisted. Set `T3CODE_HOST_ID` to choose the identity +explicitly. + When your app and server support different providers, usage totals may cover only the providers your app understands. Update the app to include newly supported providers. @@ -117,6 +121,12 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage โ†’ Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage โ†’ Limits**. +To keep them in view on web and desktop, turn on **Usage** in **Settings โ†’ Appearance โ†’ Thread details**, or right-click the thread details card. +The bottom of the thread details card then shows how much is left in each limit window of the +current provider account and in the thread's context window. Some windows, such as a weekly Opus +limit, count only one model. Hover a row to see when it resets, your pace, or +the token count. + OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected diff --git a/infra/relay/README.md b/infra/relay/README.md index 4b5ac35e438b..e80428074e7d 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,7 +1,7 @@ # T3 Connect Relay > [!NOTE] -> Sign in to T3 Connect from the app under Settings > Connections. +> Sign in to T3 Connect from the app under Settings > Sharing & access. The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 311f3cac32f1..4962e747165b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -343,6 +343,10 @@ "types": "./src/state/threadExecution.ts", "default": "./src/state/threadExecution.ts" }, + "./state/pending-user-input": { + "types": "./src/state/pendingUserInput.ts", + "default": "./src/state/pendingUserInput.ts" + }, "./state/thread-requests": { "types": "./src/state/threadRequests.ts", "default": "./src/state/threadRequests.ts" @@ -423,6 +427,10 @@ "types": "./src/threadReference.ts", "default": "./src/threadReference.ts" }, + "./pull-request-project-filter": { + "types": "./src/pullRequestProjectFilter.ts", + "default": "./src/pullRequestProjectFilter.ts" + }, "./thread-pull-request-compatibility": { "types": "./src/threadPullRequestCompatibility.ts", "default": "./src/threadPullRequestCompatibility.ts" diff --git a/packages/client-runtime/src/device/stream.test.ts b/packages/client-runtime/src/device/stream.test.ts index 7da96cb3d75b..737daf5158c0 100644 --- a/packages/client-runtime/src/device/stream.test.ts +++ b/packages/client-runtime/src/device/stream.test.ts @@ -199,7 +199,11 @@ class MjpegImage extends EventTarget { } } -function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true) { +function recoveryFixture( + platform: "ios" | "android" = "ios", + preferMjpeg = true, + serveSimOnly = false, +) { vi.useFakeTimers(); const sockets: Socket[] = []; class Socket { @@ -211,7 +215,9 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true onclose: ((event: { code: number; reason: string }) => void) | null = null; send = vi.fn(); close = vi.fn(); - constructor() { + readonly url: string; + constructor(url: string) { + this.url = url; sockets.push(this); } } @@ -273,6 +279,7 @@ function recoveryFixture(platform: "ios" | "android" = "ios", preferMjpeg = true { platform, preferMjpeg, + serveSimOnly, deviceId: "test-device", access: { httpBase: "https://test/api/device-hub", @@ -353,6 +360,56 @@ describe("shared device stream readiness and recovery", () => { } }); + it("streams and drives a serve-sim-only Android device over serve-sim's AVCC and input socket", async () => { + const { client, events, fetch, sockets, videoBodies, decoders } = recoveryFixture( + "android", + false, + true, + ); + client.start(); + await vi.advanceTimersByTimeAsync(0); + // No MJPEG priming: Simlock serves only the AVCC stream and the input socket. + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://test/api/device-hub/vendor/serve-sim/helper/test-device/stream.avcc", + ]); + expect(sockets.map((socket) => socket.url)).toEqual([ + "wss://test/api/device-hub/vendor/serve-sim/helper/ws?device=test-device", + ]); + // Simlock adds meta (5), keep-alive (6), and lease-end (7) frames to serve-sim's envelopes. + videoBodies[0]!.enqueue( + new Uint8Array([ + ...envelope(5, [0x7b, 0x7d]), + ...envelope(1, [1, 0x64, 0, 0x1f]), + ...envelope(6, []), + ...envelope(2, [2]), + ...envelope(7, [0x7b, 0x7d]), + ]), + ); + await vi.advanceTimersByTimeAsync(0); + expect(decoders[0]!.decode).toHaveBeenCalledOnce(); + + const input = sockets[0]!; + input.onmessage?.({ data: Uint8Array.of(0x83).buffer }); + input.onmessage?.({ + data: new Uint8Array([ + 0x82, + ...new TextEncoder().encode('{"width":1080,"height":2400,"orientation":"portrait"}'), + ]).buffer, + }); + expect(events.onInputConnected).toHaveBeenLastCalledWith(true, undefined); + client.sendTouch("begin", 0.25, 0.5); + client.pressButton("back"); + const sent = input.send.mock.calls.map(([payload]) => { + const bytes = payload as Uint8Array; + return [bytes[0], JSON.parse(new TextDecoder().decode(bytes.subarray(1)))]; + }); + expect(sent).toEqual([ + [0x03, { type: "begin", x: 0.25, y: 0.5 }], + [0x04, { button: "back" }], + ]); + client.stop(); + }); + it("does not repaint a delayed JPEG seed over newer decoded video", async () => { const { client, videoBodies, drawImage, decodedFrame } = recoveryFixture("ios", false); let resolveBitmap!: (bitmap: ImageBitmap) => void; diff --git a/packages/client-runtime/src/device/stream.ts b/packages/client-runtime/src/device/stream.ts index dc1720725f4f..c4b807859724 100644 --- a/packages/client-runtime/src/device/stream.ts +++ b/packages/client-runtime/src/device/stream.ts @@ -120,6 +120,8 @@ export interface DeviceStreamTarget { readonly preferMjpeg?: boolean; readonly panelId?: 1 | 3; readonly videoOnly?: boolean; + /** The host speaks only serve-sim's AVCC stream and input socket, on both platforms (Simlock). */ + readonly serveSimOnly?: boolean; } export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; @@ -388,12 +390,16 @@ export function createDeviceStreamClient( ): DeviceStreamClient { const { access, platform, deviceId } = target; const sink = "present" in output ? output : createCanvasFrameSink(output); - const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + // Android normally streams over serve-emu's socket; a serve-sim-only host serves it like iOS. + const serveSimOnly = target.serveSimOnly === true; + const avcc = platform === "ios" || serveSimOnly; + const vendor = avcc ? "/vendor/serve-sim" : "/vendor/serve-emu"; const device = encodeURIComponent(deviceId); const httpUrl = (path: string) => withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access); const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access); - const useWebCodecs = isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg); + const useWebCodecs = + isWebCodecsSupported() && !(platform === "ios" && target.preferMjpeg && !serveSimOnly); let stopped = true; let socket: WebSocket | null = null; @@ -551,7 +557,7 @@ export function createDeviceStreamClient( const paint = (source: CanvasImageSource, width: number, height: number) => { if (stopped) return; - if (platform === "android" && (screen?.width !== width || screen.height !== height)) { + if (!avcc && (screen?.width !== width || screen.height !== height)) { screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" }; events.onScreen(screen); } @@ -572,7 +578,11 @@ export function createDeviceStreamClient( }; const recoverDecoder = () => { - if (platform === "ios") fallBackToMjpeg(); + if (serveSimOnly) { + // A new AVCC request starts a fresh capture with a keyframe; there is no MJPEG. + closeDecoder(); + controller?.abort(); + } else if (platform === "ios") fallBackToMjpeg(); else { closeDecoder(); connecting("Video decoder restarted."); @@ -585,10 +595,7 @@ export function createDeviceStreamClient( const decoder = new VideoDecoder({ output: (frame) => { try { - if ( - videoDecoder === decoder && - (platform !== "ios" || feedGeneration === videoGeneration) - ) { + if (videoDecoder === decoder && (!avcc || feedGeneration === videoGeneration)) { decodedVideoGeneration = feedGeneration; paint(frame, frame.displayWidth, frame.displayHeight); } @@ -597,11 +604,7 @@ export function createDeviceStreamClient( } }, error: () => { - if ( - stopped || - videoDecoder !== decoder || - (platform === "ios" && feedGeneration !== videoGeneration) - ) + if (stopped || videoDecoder !== decoder || (avcc && feedGeneration !== videoGeneration)) return; recoverDecoder(); }, @@ -638,7 +641,7 @@ export function createDeviceStreamClient( awaitingKeyframe = false; } if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { - if (platform !== "ios") { + if (!avcc) { recoverDecoder(); return; } @@ -769,7 +772,12 @@ export function createDeviceStreamClient( if (!configured) { await reader.cancel().catch(() => {}); if (!isCurrent()) return; - if (target.videoOnly) + if (serveSimOnly) + setStatus( + "error", + `This browser cannot decode this device's ${avcCodecString(chunk.payload)} stream.`, + ); + else if (target.videoOnly) setStatus( "error", `This browser cannot decode the Duo panel's ${avcCodecString(chunk.payload)} stream.`, @@ -1070,7 +1078,7 @@ export function createDeviceStreamClient( const connectIosInput = async () => { if (stopped) return; const session = generation; - await primeIosHelper(session); + if (!serveSimOnly) await primeIosHelper(session); if (stopped || generation !== session) return; const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); ws.binaryType = "arraybuffer"; @@ -1231,7 +1239,11 @@ export function createDeviceStreamClient( iosInputAdmitted = false; iosInputUnavailable = false; connecting(); - if (platform === "ios") { + if (serveSimOnly) { + if (!target.videoOnly) void connectIosInput(); + if (useWebCodecs) void readIosVideo(); + else fail("This browser cannot decode this device's stream (WebCodecs unavailable)."); + } else if (platform === "ios") { if (!target.videoOnly) void connectIosInput(); if (useWebCodecs) void readIosVideo(); else fallBackToMjpeg(); @@ -1275,7 +1287,7 @@ export function createDeviceStreamClient( const send = (payload: Uint8Array | string) => { if ( !stopped && - (platform !== "ios" || (iosInputAdmitted && !iosInputUnavailable)) && + (!avcc || (iosInputAdmitted && !iosInputUnavailable)) && socket?.readyState === WebSocket.OPEN ) socket.send(payload); @@ -1326,7 +1338,7 @@ export function createDeviceStreamClient( startDuoVideo(panels); }, sendTouch: (phase, x, y) => { - if (platform === "ios") { + if (avcc) { send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) })); return; } @@ -1334,7 +1346,7 @@ export function createDeviceStreamClient( send(JSON.stringify({ type: "touch", action, x, y })); }, sendKey: (event, phase) => { - if (platform === "ios") { + if (avcc) { const usage = hidUsageForCode(event.code); if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage })); return; @@ -1348,7 +1360,7 @@ export function createDeviceStreamClient( } }, sendText: (text) => { - if (platform !== "android") return; + if (avcc) return; let chunk = ""; let chunkBytes = 0; let sentChunks = 0; @@ -1366,15 +1378,17 @@ export function createDeviceStreamClient( if (chunk.length > 0) send(JSON.stringify({ type: "text", text: chunk })); }, pressButton: (button) => { - if (platform === "ios") { + if (avcc) { const name = button === "home" ? "home" - : button === "appSwitcher" + : button === "appSwitcher" || button === "recents" ? "app_switcher" : button === "power" ? "lock" - : null; + : button === "back" && platform === "android" + ? "back" + : null; if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name })); return; } diff --git a/packages/client-runtime/src/operations/commands.performance.test.ts b/packages/client-runtime/src/operations/commands.performance.test.ts index 2378407c2df7..d4be3f1a9f45 100644 --- a/packages/client-runtime/src/operations/commands.performance.test.ts +++ b/packages/client-runtime/src/operations/commands.performance.test.ts @@ -223,7 +223,7 @@ describe("routine command transport budget", () => { if (command.type !== "message.dispatch") { throw new Error(`Expected message.dispatch, received ${command.type}`); } - if (!("sequence" in receipt)) { + if ("projection" in receipt) { throw new Error("Expected a dispatch receipt"); } const { deliveryIntent: _deliveryIntent, ...formerCommand } = command; diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 753986102616..ccc5bf6ef280 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -199,6 +199,63 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it.each(["already expired", "expires during discovery"])( + "retries an unchanged catalog that %s after the short cooldown", + async (expiry) => { + const start = Date.parse("2026-01-01T00:05:00.000Z"); + vi.useFakeTimers({ now: expiry === "expires during discovery" ? start - 1 : start }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, responses, run, unmount } = setup( + expiry === "expires during discovery" + ? withSnapshot("2026-01-01T00:00:00.000Z", true) + : stale, + [ + () => { + if (expiry === "expires during discovery") vi.setSystemTime(start + 1); + return stale; + }, + ], + ); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(9_999); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + responses.push(() => withSnapshot("2026-01-01T00:05:10.000Z")); + await vi.advanceTimersByTimeAsync(1); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(2); + unmount(); + }, + ); + + it("keeps the TTL guard for a newly scanned catalog when the client clock is ahead", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T01:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z"), [ + () => withSnapshot("2026-01-01T00:01:00.000Z"), + ]); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("bounds retries when discovery repeatedly returns the same expired catalog", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:05:00.000Z") }); + const stale = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, run, unmount } = setup(stale, [() => stale, () => stale, () => stale]); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(3); + unmount(); + }); + it("does not rescan a current snapshot, and rescans once it ages past the TTL", () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 33938ac053ca..a22fc7b9f9db 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -154,7 +154,7 @@ interface ProviderWorkspaceSnapshotRefreshState { readonly requestedAt: number | null; /** A failed scan is not retried before this time. */ readonly retryNotBefore: number | null; - /** Scans in a row that ended without a complete snapshot. */ + /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; } @@ -193,8 +193,10 @@ function planProviderWorkspaceSnapshotRefresh( const state = { requestedAt, retryNotBefore, - // A complete snapshot ends the run of failures. - failedAttempts: hasSnapshot ? 0 : previous.failedAttempts, + failedAttempts: + lost || (requestedAt !== null && now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) + ? 0 + : previous.failedAttempts, hadSnapshot: hasSnapshot, }; const refresh = @@ -315,9 +317,18 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { { environmentId, input: { instanceId, cwd } }, { reportFailure: false }, ).then((result) => { + const refreshedProvider = + result._tag === "Success" ? findProvider(result.value.providers) : undefined; + const snapshot = + refreshedProvider && resolveProviderWorkspaceSnapshot(refreshedProvider, cwd); + // A new server timestamp proves progress even when the client clock is ahead. + // @effect-diagnostics-next-line globalDate:off + const completedAt = Date.now(); const refreshed = - result._tag === "Success" && - hasCompleteProviderWorkspaceSnapshot(findProvider(result.value.providers), cwd); + hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); if (!refreshed) retryLater(); }, retryLater); return plan.state; diff --git a/packages/client-runtime/src/pullRequestEditing.ts b/packages/client-runtime/src/pullRequestEditing.ts index 96b4d5c494f9..e65c19009e65 100644 --- a/packages/client-runtime/src/pullRequestEditing.ts +++ b/packages/client-runtime/src/pullRequestEditing.ts @@ -25,6 +25,41 @@ export function canEditPullRequestChangeRequest(detail: EditingSubject): boolean ); } +export function canEditPullRequestMaintainerEdits( + detail: Pick< + PullRequestDetail, + "state" | "capabilities" | "viewerPermissions" | "maintainerCanModify" + >, +): boolean { + return ( + detail.state === "open" && + detail.capabilities.edit?.maintainerEdits === true && + detail.viewerPermissions.maintainerEdits === true && + detail.maintainerCanModify !== undefined + ); +} + +export interface MaintainerEditsChoice { + readonly key: string; + readonly observedAt: number | undefined; + readonly value: boolean; +} + +/** + * A saved choice ends at the first host reading delivered after the save. Readers refresh once it + * succeeds, which drops reads still in flight, so that reading started after the write. + */ +export function maintainerEditsChoiceInForce( + choice: MaintainerEditsChoice | null, + key: string, + observedAt: number | undefined, + saving: boolean, +): MaintainerEditsChoice | null { + if (choice === null || choice.key !== key) return null; + if (saving) return choice.observedAt === observedAt ? choice : { ...choice, observedAt }; + return observedAt !== undefined && choice.observedAt === observedAt ? choice : null; +} + /** Hosts do not support rewriting review summaries through comment mutations. */ export function canEditPullRequestComment( detail: EditingSubject, diff --git a/packages/client-runtime/src/pullRequestLinkInput.test.ts b/packages/client-runtime/src/pullRequestLinkInput.test.ts index 3747fdc833a4..79bbc96bdea3 100644 --- a/packages/client-runtime/src/pullRequestLinkInput.test.ts +++ b/packages/client-runtime/src/pullRequestLinkInput.test.ts @@ -161,6 +161,13 @@ describe("parsePullRequestReference", () => { ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"); }); + it.each([ + "https://code.example.com/group/project/-/merge_requests/42", + "http://git.internal/group/sub/project/-/merge_requests/7", + ])("accepts self-hosted GitLab merge request URL %s", (url) => { + expect(parsePullRequestReference(url)).toBe(url); + }); + it("accepts gh pr checkout commands with raw numbers", () => { expect(parsePullRequestReference("gh pr checkout 42")).toBe("42"); }); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/packages/client-runtime/src/pullRequestProjectFilter.ts similarity index 82% rename from apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts rename to packages/client-runtime/src/pullRequestProjectFilter.ts index 040f3537446c..03e47dcafab1 100644 --- a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts +++ b/packages/client-runtime/src/pullRequestProjectFilter.ts @@ -1,8 +1,9 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; - -interface FilterProject extends AssignableProject { +interface FilterProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly repositoryIdentity?: { readonly canonicalKey?: string | undefined } | null | undefined; readonly title: string; readonly workspaceRoot: string; } @@ -25,7 +26,7 @@ function distinguishTitles( export function pullRequestFilterProjects( projects: ReadonlyArray, environmentLabels: ReadonlyMap, - selectedProject?: Pick, + selectedProject?: Pick, ) { const byRepository = new Map(); for (const project of projects) { @@ -45,7 +46,7 @@ export function pullRequestFilterProjects( ); const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); - return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + return distinguishTitles(byEnvironment, (project) => project.id).sort((left, right) => left.title.localeCompare(right.title), ); } diff --git a/packages/client-runtime/src/state/pendingUserInput.test.ts b/packages/client-runtime/src/state/pendingUserInput.test.ts new file mode 100644 index 000000000000..7f5a2b0b84c3 --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildPendingUserInputAnswers, + isPendingUserInputOptionSelected, + resolvePendingUserInputAnswer, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, +} from "./pendingUserInput.ts"; + +const singleSelectQuestion = { + id: "scope", + header: "Scope", + question: "What should the plan target first?", + options: [ + { label: "Orchestration-first", description: "Focus on orchestration first" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const nativeChoiceQuestion = { + id: "result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, +} as const; + +describe("resolvePendingUserInputAnswer", () => { + it("preserves exact editor text, including a deliberately cleared answer", () => { + const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" }; + expect(resolvePendingUserInputAnswer(question, { customAnswer: question.initialAnswer })).toBe( + question.initialAnswer, + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " Edited message\n\n" })).toBe( + " Edited message\n\n", + ); + expect(resolvePendingUserInputAnswer(question, { customAnswer: "" })).toBe(""); + expect(resolvePendingUserInputAnswer(question, { customAnswer: " \n" })).toBe(" \n"); + expect( + setPendingUserInputCustomAnswer( + question, + { selectedOptionValues: ["Orchestration-first"] }, + "", + ), + ).toEqual({ customAnswer: "" }); + }); + + it("prefers a custom answer over selected options", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + selectedOptionValues: ["Orchestration-first"], + customAnswer: "Keep the existing envelope for one release", + }), + ).toBe("Keep the existing envelope for one release"); + }); + + it("does not replace a required choice with a custom answer", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["second"], + customAnswer: "Use another result", + }), + ).toBe("second"); + }); + + it("does not submit labels or unknown values when an option has a value", () => { + expect( + resolvePendingUserInputAnswer(nativeChoiceQuestion, { + selectedOptionValues: ["Result", "unknown"], + }), + ).toBeNull(); + }); + + it("matches a label only for an option without a value", () => { + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { selectedOptionValues: [" Node.js "] }), + ).toBe("Node.js"); + }); + + it("accepts ready attachment-only answers while preserving selected options", () => { + expect(resolvePendingUserInputAnswer(singleSelectQuestion, { attachmentCount: 1 })).toBe(""); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + selectedOptionValues: ["Node.js"], + }), + ).toBe("Node.js"); + expect( + resolvePendingUserInputAnswer(singleSelectQuestion, { + attachmentCount: 1, + attachmentsBlocked: true, + }), + ).toBeNull(); + expect( + resolvePendingUserInputAnswer( + { ...singleSelectQuestion, allowCustomAnswer: false }, + { attachmentCount: 1 }, + ), + ).toBeNull(); + }); +}); + +describe("togglePendingUserInputOptionSelection", () => { + it("selects and removes options with the same label independently", () => { + const question = { ...nativeChoiceQuestion, multiSelect: true }; + const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t"); + const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second"); + + expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({ + result: [" first\t", "second"], + }); + + const secondSelected = togglePendingUserInputOptionSelection( + question, + bothSelected, + " first\t", + ); + expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({ + result: ["second"], + }); + }); + + it("submits raw option values and leaves the draft alone for an unknown option", () => { + const first = togglePendingUserInputOptionSelection( + nativeChoiceQuestion, + undefined, + " first\t", + ); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, " first\t")).toBe(true); + expect(isPendingUserInputOptionSelected(nativeChoiceQuestion, first, "second")).toBe(false); + expect(buildPendingUserInputAnswers([nativeChoiceQuestion], { result: first })).toEqual({ + result: " first\t", + }); + expect(togglePendingUserInputOptionSelection(nativeChoiceQuestion, first, "Result")).toBe( + first, + ); + }); +}); + +describe("setPendingUserInputCustomAnswer", () => { + it("rejects arbitrary text when the provider only accepts offered options", () => { + expect(setPendingUserInputCustomAnswer(nativeChoiceQuestion, undefined, "Other")).toEqual({}); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { result: { customAnswer: "Other" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers([nativeChoiceQuestion], { + result: { selectedOptionValues: ["second"], customAnswer: "stale draft" }, + }), + ).toEqual({ result: "second" }); + }); +}); + +const compatQuestion = { + id: "compat", + header: "Compat", + question: "How strict should compatibility be?", + options: [{ label: "Keep current envelope", description: "Preserve current wire format" }], + multiSelect: false, +} as const; + +describe("buildPendingUserInputAnswers", () => { + it("returns a canonical answer map for complete prompts", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, compatQuestion], { + scope: { selectedOptionValues: ["Orchestration-first"] }, + compat: { customAnswer: "Keep the current envelope for one release window" }, + }), + ).toEqual({ + scope: "Orchestration-first", + compat: "Keep the current envelope for one release window", + }); + }); +}); diff --git a/packages/client-runtime/src/state/pendingUserInput.ts b/packages/client-runtime/src/state/pendingUserInput.ts new file mode 100644 index 000000000000..1c71cdb29b4d --- /dev/null +++ b/packages/client-runtime/src/state/pendingUserInput.ts @@ -0,0 +1,176 @@ +/** The question fields answer handling reads. Web and mobile question types both fit it. */ +export interface PendingUserInputQuestion { + readonly id: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>; + readonly multiSelect?: boolean | undefined; + readonly allowCustomAnswer?: boolean | undefined; + readonly initialAnswer?: string | undefined; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionValues?: ReadonlyArray; + readonly customAnswer?: string; + readonly attachmentCount?: number; + readonly attachmentsBlocked?: boolean; +} + +export type PendingUserInputAnswer = string | ReadonlyArray; + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// Provider option values stay exact, including whitespace. Only options without a value match by label. +function resolvePendingUserInputOptionValue( + question: PendingUserInputQuestion, + value: string, +): string | null { + if (question.options.some((option) => option.value === value)) { + return value; + } + + const label = value.trim(); + return label.length > 0 && + question.options.some((option) => option.value === undefined && option.label.trim() === label) + ? label + : null; +} + +/** The draft's selections that name an offered option, deduplicated, in selection order. */ +export function normalizeSelectedOptionValues( + question: PendingUserInputQuestion, + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set( + value + .map((entry) => resolvePendingUserInputOptionValue(question, entry)) + .filter((entry): entry is string => entry !== null), + ), + ); +} + +/** The answer to submit for one question, or null while it is unanswered. */ +export function resolvePendingUserInputAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, +): PendingUserInputAnswer | null { + if (draft?.attachmentsBlocked) return null; + const customAnswer = + question.allowCustomAnswer === false + ? null + : question.initialAnswer !== undefined + ? (draft?.customAnswer ?? null) + : normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer !== null && customAnswer.length > 0) { + return customAnswer; + } + + const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + if (question.multiSelect) { + return selectedOptionValues.length > 0 + ? selectedOptionValues + : (customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)); + } + return ( + selectedOptionValues[0] ?? + customAnswer ?? + (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) + ); +} + +export function setPendingUserInputCustomAnswer( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + if (question.allowCustomAnswer === false) { + return draft ?? {}; + } + + const selectedOptionValues = + question.initialAnswer !== undefined || customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionValues(question, draft?.selectedOptionValues); + return { + customAnswer, + ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): boolean { + if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + return ( + resolvedOptionValue !== null && + normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes( + resolvedOptionValue, + ) + ); +} + +/** Toggles an offered option. An unknown option returns the same draft. */ +export function togglePendingUserInputOptionSelection( + question: PendingUserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionValue: string, +): PendingUserInputDraftAnswer { + const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue); + if (resolvedOptionValue === null) { + return draft ?? {}; + } + + if (question.multiSelect) { + const selectedOptionValues = normalizeSelectedOptionValues( + question, + draft?.selectedOptionValues, + ); + const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue) + ? selectedOptionValues.filter((value) => value !== resolvedOptionValue) + : [...selectedOptionValues, resolvedOptionValue]; + + return { + customAnswer: "", + ...(nextSelectedOptionValues.length > 0 + ? { selectedOptionValues: nextSelectedOptionValues } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionValues: [resolvedOptionValue], + }; +} + +/** Every question's answer keyed by question id, or null while any is unanswered. */ +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); + if (answer === null) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index ede83d82957a..03db9b869cc0 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -201,6 +201,12 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + createFromThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:create-from-thread", + tag: WS_METHODS.projectsCreateFromThread, + scheduler: projectScheduler, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), writeFile: createEnvironmentRpcCommand(runtime, { label: "environment-data:projects:write-file", tag: WS_METHODS.projectsWriteFile, diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 963b3a7bfdeb..519ccd025b07 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1212,6 +1212,35 @@ describe("runtime command runner", () => { }); describe("persisted environment queries", () => { + it.effect("keeps an in-memory cached page pending until revalidation settles", () => + Effect.scoped( + Effect.gen(function* () { + const refreshStarted = Latch.makeUnsafe(); + const finishRefresh = Latch.makeUnsafe(); + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.suspend(() => { + if (++executions === 1) return Effect.succeed("cached"); + refreshStarted.openUnsafe(); + return finishRefresh.await.pipe(Effect.as("fresh")); + }), + { persistence: { load: () => Effect.succeedNone, save: () => Effect.void } }, + ); + const registry = yield* mountEnvironmentQuery(harness.atom); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("cached"); + registry.refresh(harness.atom); + yield* refreshStarted.await; + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: true, + }); + finishRefresh.openUnsafe(); + expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh"); + }), + ), + ); + it.effect( "restores cached data in a new registry while offline, then refreshes on reconnect", () => diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 812a72afb526..6abb938a391d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -970,6 +970,17 @@ export function createServerEnvironmentAtoms( }), ); + // A track switch's desktop host already on its track's newest build only saved the + // track; nothing restarts. + if ( + result.method === "desktop-app" && + result.desktopUpdateToken === undefined && + result.targetVersion === fromVersion + ) { + atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); + return result; + } + yield* nudgeReconnectDuringUpdateRestart({ stateChanges: environmentRegistry.stateChanges(target.environmentId), retryNow: environmentRegistry.retryNow(target.environmentId), diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 000000000000..740ab2229bdb --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type AuthSessionState } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as RpcHttp from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createEnvironmentSessionAtoms } from "./session.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: { _tag: "Bearer", token: "bearer-token" }, + target: TARGET, +}; +const SESSION = { + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "t3_session", + }, + scopes: ["orchestration:read"], +} satisfies AuthSessionState; + +const rpcSession = (): RpcSession => ({ closed: Effect.never }) as unknown as RpcSession; + +const makeHarness = Effect.fn("TestSessionState.makeHarness")(function* ( + reply: (requestNumber: number) => Promise, +) { + let requestCount = 0; + const transport = yield* SubscriptionRef.make(Option.some(rpcSession())); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: transport, + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + followStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + RpcHttp.layerRemoteHttpClient(async () => reply(++requestCount)), + // The test's clock, so the retry wait moves with `TestClock.adjust`. + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const results = yield* Queue.unbounded>(); + const atom = createEnvironmentSessionAtoms(runtime).sessionStateAtom(TARGET.environmentId); + const unsubscribe = registry.subscribe(atom, (result) => Queue.offerUnsafe(results, result), { + immediate: true, + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const nextSettled = Effect.fn("TestSessionState.nextSettled")(function* () { + for (;;) { + const result = yield* Queue.take(results); + if (!result.waiting && result._tag !== "Initial") return result; + } + }); + return { transport, nextSettled }; +}); + +const unreachable = () => Promise.reject(new TypeError("Network request failed")); + +describe("environment session state", () => { + it.effect("fetches permissions again when the connection is re-established", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber === 1 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); + + it.effect("retries a failed permission check with backoff that restarts per connection", () => + Effect.gen(function* () { + const harness = yield* makeHarness((requestNumber) => + requestNumber <= 3 ? unreachable() : Promise.resolve(Response.json(SESSION)), + ); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* SubscriptionRef.set(harness.transport, Option.some(rpcSession())); + expect((yield* harness.nextSettled())._tag).toBe("Failure"); + + yield* TestClock.adjust("2 seconds"); + expect(yield* harness.nextSettled()).toMatchObject({ _tag: "Success", value: SESSION }); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index efdf0b37c0a0..90afa29f9d05 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -14,6 +14,7 @@ import type { PreparedConnection } from "../connection/model.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./environmentStreams.ts"; @@ -39,6 +40,8 @@ function initialConfigOption( const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; const MAX_SESSION_STATE_TIMEOUT_MS = 60_000; +const SESSION_STATE_RETRY_BASE_MS = 2_000; +const SESSION_STATE_RETRY_MAX_MS = 60_000; export const fetchEnvironmentSessionState = Effect.fn( "clientRuntime.state.fetchEnvironmentSessionState", @@ -136,13 +139,38 @@ function makeEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); - const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => - runtime + const transportSessionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.session)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => { + let consecutiveFailures = 0; + let backoffConnection: RpcSession | null = null; + return runtime .atom((get) => { const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); - if (prepared === null) { + // Each established connection fetches anew, and a failure retries while it stays up, + // so a fetch that failed mid-reconnect cannot outlive the socket that recovered. + const connection = AsyncResult.getOrElse(get(transportSessionAtom(environmentId)), () => + Option.none(), + ); + if (prepared === null || Option.isNone(connection)) { return Effect.never; } + if (connection.value !== backoffConnection) { + backoffConnection = connection.value; + consecutiveFailures = 0; + } return Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); const remoteAuthorization = yield* Effect.serviceOption( @@ -155,14 +183,31 @@ function makeEnvironmentSessionAtoms( signer, remoteAuthorization, }).pipe(Effect.provideService(HttpClient.HttpClient, client.value)); - }); + }).pipe( + Effect.tap(() => + Effect.sync(() => { + consecutiveFailures = 0; + }), + ), + Effect.tapError(() => { + const delayMs = Math.min( + SESSION_STATE_RETRY_BASE_MS * 2 ** consecutiveFailures, + SESSION_STATE_RETRY_MAX_MS, + ); + consecutiveFailures += 1; + return Effect.sleep(delayMs).pipe( + Effect.andThen(Effect.sync(() => get.refreshSelf())), + Effect.forkScoped, + ); + }), + ); }) .pipe( Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel(`environment-session-state:${environmentId}`), - ), - ); + ); + }); const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( diff --git a/packages/client-runtime/src/state/threadCheckpoints.ts b/packages/client-runtime/src/state/threadCheckpoints.ts index 4501007e2005..ee2fe45d9c75 100644 --- a/packages/client-runtime/src/state/threadCheckpoints.ts +++ b/packages/client-runtime/src/state/threadCheckpoints.ts @@ -3,6 +3,7 @@ import type { CheckpointRef, CheckpointScopeId, MessageId, + OrchestrationV2CheckpointFileTotals, OrchestrationV2ThreadProjection, RunId, } from "@t3tools/contracts"; @@ -15,12 +16,14 @@ export interface ThreadCheckpointSummary { readonly checkpointTurnCount: number; readonly checkpointRef: CheckpointRef; readonly status: "ready" | "missing" | "error" | "stale"; + /** A prefix of the changed files when `filesTotal` is set; see `checkpointFileTotals`. */ readonly files: ReadonlyArray<{ readonly path: string; readonly kind: string; readonly additions: number; readonly deletions: number; }>; + readonly filesTotal?: OrchestrationV2CheckpointFileTotals; readonly assistantMessageId: MessageId | null; readonly completedAt: string; } @@ -43,9 +46,23 @@ export function deriveThreadCheckpointSummaries( checkpointRef: checkpoint.ref, status: checkpoint.status, files: checkpoint.files, + ...(checkpoint.filesTotal === undefined ? {} : { filesTotal: checkpoint.filesTotal }), assistantMessageId, completedAt: DateTime.formatIso(checkpoint.capturedAt), }, ]; }); } + +export function checkpointFileTotals( + checkpoint: Pick, +): OrchestrationV2CheckpointFileTotals { + if (checkpoint.filesTotal !== undefined) return checkpoint.filesTotal; + let additions = 0; + let deletions = 0; + for (const file of checkpoint.files) { + additions += file.additions; + deletions += file.deletions; + } + return { count: checkpoint.files.length, additions, deletions }; +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0c9380037879..e1f30998caef 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -409,6 +409,12 @@ export function createEnvironmentThreadDetailAtoms( Atom.withLabel(`environment-thread-status:${key}`), ), ); + const sequenceAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).sequence).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-sequence:${key}`), + ), + ); const errorAtomFamily = Atom.family((key: string) => Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( Atom.setIdleTTL(0), @@ -439,6 +445,7 @@ export function createEnvironmentThreadDetailAtoms( quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), + sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), historyAtom: (ref: ScopedThreadRef) => historyAtomFamily(threadKey(ref)), }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index accf5c98df9a..ba87930bebbd 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -151,6 +151,7 @@ export const REASONING_EFFORT_OPTION_IDS: ReadonlyArray = [ "reasoning", "variant", "reasoning_effort", + "thinking_level", ]; export function formatModelSelectionEffort( diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 67b227089ab1..3b53b68b2f4f 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -14,6 +14,8 @@ export interface EnvironmentThreadState { * disconnect path. */ readonly history: ThreadHistoryMeta; + /** The last server sequence `data` reflects, so readers can tell a write has landed without searching paged-out history. */ + readonly sequence: number; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { @@ -21,4 +23,21 @@ export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { status: "empty", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: 0, }; + +/** + * A thread holds a write once it has applied the write's commit sequence. A bounded + * projection can page the message out later, so finding it is only a fallback for + * servers that do not report the sequence. + */ +export function threadHasCaughtUpTo( + write: { readonly messageId: string; readonly sequence?: number | undefined }, + thread: { + readonly sequence: number; + readonly messages: ReadonlyArray<{ readonly id: string }> | undefined; + }, +): boolean { + if (write.sequence !== undefined && thread.sequence >= write.sequence) return true; + return thread.messages?.some((entry) => entry.id === write.messageId) ?? false; +} diff --git a/packages/client-runtime/src/state/threadVisits.test.ts b/packages/client-runtime/src/state/threadVisits.test.ts index a99417534ea7..adcac7139e82 100644 --- a/packages/client-runtime/src/state/threadVisits.test.ts +++ b/packages/client-runtime/src/state/threadVisits.test.ts @@ -111,6 +111,24 @@ it("keeps a mark-unread on the open thread until new activity lands", () => { expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); }); +it("keeps mark unread sticky after an expired wake is retired", () => { + using t = setup("thread-unread-retired-wake", { + updatedAt: at(10), + lastVisitedAt: at(0), + latestRun: { completedAt: at(9) }, + wokeAt: at(15), + }); + t.mount(); + expect(t.visits).toEqual([{ threadId: t.ref.threadId, visitedAt: at(15), opened: true }]); + t.update({ lastVisitedAt: at(15) }); + t.update({ lastVisitedAt: at(8), wokeAt: null }); + vi.advanceTimersByTime(VISIT_DISPATCH_THROTTLE_MS); + expect(t.visits).toHaveLength(1); + + t.update({ updatedAt: at(30) }); + expect(t.visits.slice(1)).toEqual([{ threadId: t.ref.threadId, visitedAt: at(30) }]); +}); + it("waits for the shell and records local visits for servers without tracking", () => { using t = setup("thread-local", null); t.mount(); diff --git a/packages/client-runtime/src/state/threadVisits.ts b/packages/client-runtime/src/state/threadVisits.ts index 08d05909a2da..c08f47ed0143 100644 --- a/packages/client-runtime/src/state/threadVisits.ts +++ b/packages/client-runtime/src/state/threadVisits.ts @@ -115,7 +115,8 @@ export function createThreadVisitTracker(options: ThreadVisitTrackerOptions) { } const lastVisitedAtMs = shell.lastVisitedAt ? Date.parse(shell.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAtMs) && lastVisitedAtMs >= updatedAtMs) return; - if (lastDispatchedUpdatedAt === visitedAt) return; + if (lastDispatchedUpdatedAt !== null && Date.parse(lastDispatchedUpdatedAt) >= updatedAtMs) + return; const completedAtMs = shell.latestRun?.completedAt ? Date.parse(shell.latestRun.completedAt) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c8c212db41ca..eaf1751a1c2d 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -678,7 +678,7 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ cached: BASE_PROJECTION }); yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1)); - yield* awaitThreadState( + const live = yield* awaitThreadState( harness.observed, (value) => value.status === "live" && @@ -686,6 +686,8 @@ describe("EnvironmentThreads", () => { value.data.value.thread.title === "Live title", ); + // Readers compare this to a command's commit sequence; it moves with the data it describes. + expect(live.sequence).toBe(CACHED_SNAPSHOT_SEQUENCE + 1); expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE); expect(yield* Ref.get(harness.loaderCalls)).toBe(0); }), @@ -1288,6 +1290,7 @@ describe("EnvironmentThreads", () => { status: "cached", error: Option.none(), history: { ...EMPTY_THREAD_HISTORY_META, historyCursor: null, expanded: true }, + sequence: CACHED_SNAPSHOT_SEQUENCE, }, sequence: CACHED_SNAPSHOT_SEQUENCE, persisted: true, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 450d6355a46c..9b941356246d 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -224,6 +224,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) : Option.none(); const cachedThread = Option.map(cached, (snapshot) => snapshot.projection); + // Seed the resume cursor from the cached snapshot so a warm cache can catch up + // via `afterSequence` instead of re-downloading the full thread body. + const initialSequence = + retained?.sequence ?? + Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const initialState: EnvironmentThreadState = retained ? cachedThreadState(retained.state) : { @@ -234,15 +239,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onNone: () => EMPTY_THREAD_HISTORY_META, onSome: historyMetaFromCachedSnapshot, }), + sequence: initialSequence, }; const state = yield* SubscriptionRef.make(initialState); const canLoadHistory = Option.isSome(httpClient) && Option.isSome(historyController); const acceptsBoundedSocketSnapshots = yield* Ref.make(canLoadHistory); - // Seed the resume cursor from the cached snapshot so a warm cache can catch up - // via `afterSequence` instead of re-downloading the full thread body. - const initialSequence = - retained?.sequence ?? - Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }); const lastSequence = yield* SubscriptionRef.make(initialSequence); let committed: ThreadResumeSnapshot = { state: initialState, @@ -400,6 +401,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ) { const waiting = yield* Ref.get(awaitingCompletion); + const sequence = yield* SubscriptionRef.get(lastSequence); // Atomic with concurrent history meta updates: never get-then-set the whole // state when only the projection changes. Bounded installs pass history so // projection + cursor persist together in one enqueue. @@ -420,6 +422,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ("live" as const), error: previous.error, history, + sequence, }; }); // Active projections can update many times per second and retain large tool @@ -451,6 +454,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: "deleted", error: Option.none(), history: EMPTY_THREAD_HISTORY_META, + sequence: yield* SubscriptionRef.get(lastSequence), }); yield* remember; if (resumeCache !== undefined && resumeCache.owner !== owner) return; @@ -571,7 +575,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (applied._tag !== "noop") result = applied; if (applied._tag === "delete") break; } - return [result, current]; + // A batch that changed nothing keeps its state object, so readers do not re-render. + return [result, result._tag === "noop" ? current : { ...current, sequence }]; }, ); @@ -796,7 +801,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); if (result._tag !== "loaded") return result; - if (complete) yield* remember; + if (complete) yield* applyLock.withPermits(1)(remember); nextCursor = keepPaging ? page.nextCursor : null; } if (nextCursor === null) return { _tag: "loaded" } as const; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index c87a9fed4103..2a032000896f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.3", + "version": "0.1.5", "private": true, "files": [ "dist" diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 3cd80feea6b7..f9564b2e2441 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -33,6 +33,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.pullRequestsSetLabels]: AuthSourceControlWriteScope, // Writing a guide runs a model on this environment's providers, which reading one does not. [WS_METHODS.pullRequestsGenerateReviewGuide]: AuthOrchestrationOperateScope, + [WS_METHODS.projectsCreateFromThread]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlCloneRepository]: AuthSourceControlWriteScope, [WS_METHODS.sourceControlPublishRepository]: AuthSourceControlWriteScope, // Signing in replaces the server's GitHub credential, as saving a token in Settings does. diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index 6de9f15125d4..e49e54a09fdf 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -4,6 +4,7 @@ import { DeviceBootError, DeviceServiceState, EasDeviceHostConfig, + SimlockDeviceHostConfig, EasDeviceHostConfigs, SshDeviceHostConfigs, } from "./device.ts"; @@ -49,6 +50,7 @@ describe("configured device hosts", () => { it("marks the EAS access token as a secret setting", () => { expect(secretSettingsFields(EasDeviceHostConfig)).toEqual(["accessToken"]); + expect(secretSettingsFields(SimlockDeviceHostConfig)).toEqual(["agentToken"]); }); }); @@ -63,6 +65,7 @@ describe("device state for clients from before EAS hosts", () => { hosts: [ { ...summary, id: "local", kind: "local" as const }, { ...summary, id: "eas", kind: "eas" as const }, + { ...summary, id: "pool", kind: "simlock" as const }, ], hostStatus: "ready" as const, hostStatuses: {}, @@ -74,7 +77,7 @@ describe("device state for clients from before EAS hosts", () => { revision: 1, }; - it("sends EAS hosts where an older client does not look, and reads them back", () => { + it("sends EAS and Simlock hosts where an older client does not look, and reads them back", () => { const wire = encodeState(state); expect(decodeOlderState(wire).hosts.map((host) => host.id)).toEqual(["local"]); expect(decodeState(wire).hosts).toEqual(state.hosts); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index dbb90f6d41e5..24b72dbe26d7 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -68,7 +68,32 @@ export const EasDeviceHostConfig = Schema.Struct({ ); export type EasDeviceHostConfig = typeof EasDeviceHostConfig.Type; -export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig; +/** + * Alpha: devices leased from a Simlock gateway (https://incubator.callstack.com/simlock). + * T3 never installs Simlock; the gateway and its workers are the user's own. + */ +export const SimlockDeviceHostConfig = Schema.Struct({ + kind: Schema.Literal("simlock"), + id: ConfiguredDeviceHostId, + label: TrimmedNonEmptyString, + /** The gateway's origin, such as https://gateway.example.ts.net:4700. */ + gatewayUrl: TrimmedNonEmptyString.check(Schema.isPattern(/^https?:\/\/[^\s/]+\/?$/)), + /** Leases, drives, watches, and takes input on its own leases. Kept in the secret store. */ + agentToken: TrimmedString.pipe( + Schema.withDecodingDefaultKey(Effect.succeed("")), + Schema.annotateKey({ settingsSecret: true }), + ), + maxLeases: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(2)), + ), + /** A lease the Device panel keeps alive ends this long after it started. */ + maxLeaseMinutes: Schema.Int.check(Schema.isBetween({ minimum: 5, maximum: 240 })).pipe( + Schema.withDecodingDefaultKey(Effect.succeed(60)), + ), +}); +export type SimlockDeviceHostConfig = typeof SimlockDeviceHostConfig.Type; + +export type DeviceHostConfig = SshDeviceHostConfig | EasDeviceHostConfig | SimlockDeviceHostConfig; const uniqueHostIds = Schema.makeFilter( (hosts: ReadonlyArray<{ readonly id: string }>) => @@ -85,6 +110,9 @@ export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check(uniq */ export const EasDeviceHostConfigs = Schema.Array(EasDeviceHostConfig).check(uniqueHostIds); +/** Kept apart from the other host lists for the same reason as EAS hosts. */ +export const SimlockDeviceHostConfigs = Schema.Array(SimlockDeviceHostConfig).check(uniqueHostIds); + export const DeviceEasAccountInput = Schema.Struct({ /** A saved EAS host whose stored token is checked when no new token is given. */ hostId: Schema.optional(DeviceHostId), @@ -99,6 +127,33 @@ export const DeviceEasAccount = Schema.Struct({ }); export type DeviceEasAccount = typeof DeviceEasAccount.Type; +export const DeviceSimlockIdentityInput = Schema.Struct({ + /** Without one, the gateway this machine's agent-device points at. */ + gatewayUrl: Schema.optional(TrimmedString), + /** A saved Simlock host whose stored token is checked when no new token is given. */ + hostId: Schema.optional(DeviceHostId), + agentToken: Schema.optional(TrimmedString), +}); +export type DeviceSimlockIdentityInput = typeof DeviceSimlockIdentityInput.Type; + +export const DeviceSimlockIdentity = Schema.Struct({ + gatewayUrl: Schema.String, + /** The label the gateway gave the token. */ + signedInAs: Schema.String, + /** `env`: this machine's AGENT_DEVICE_DAEMON_AUTH_TOKEN, used because it targets this gateway. */ + tokenSource: Schema.Literals(["entered", "settings", "env"]), +}); +export type DeviceSimlockIdentity = typeof DeviceSimlockIdentity.Type; + +export class DeviceSimlockIdentityError extends Schema.TaggedError()( + "DeviceSimlockIdentityError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + /** Checking an Expo credential failed; no device host is involved yet. */ export class DeviceEasAccountError extends Schema.TaggedError()( "DeviceEasAccountError", @@ -161,7 +216,15 @@ const EasDeviceHostSummary = Schema.Struct({ ...deviceHostSummaryFields, kind: Schema.Literal("eas"), }); -export const DeviceHostSummary = Schema.Union([MachineDeviceHostSummary, EasDeviceHostSummary]); +const SimlockDeviceHostSummary = Schema.Struct({ + ...deviceHostSummaryFields, + kind: Schema.Literal("simlock"), +}); +export const DeviceHostSummary = Schema.Union([ + MachineDeviceHostSummary, + EasDeviceHostSummary, + SimlockDeviceHostSummary, +]); export type DeviceHostSummary = typeof DeviceHostSummary.Type; export const DeviceHostStatus = Schema.Literals([ @@ -198,6 +261,8 @@ const deviceServiceStateFields = { supportsToolInspection: Schema.optional(Schema.Boolean), /** Servers from before EAS hosts ignore `easDeviceHosts`, so clients offer them only when set. */ supportsEasHosts: Schema.optional(Schema.Boolean), + /** Servers from before Simlock hosts ignore `simlockDeviceHosts`. */ + supportsSimlockHosts: Schema.optional(Schema.Boolean), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), hostStatuses: DeviceHostStatuses, @@ -221,6 +286,7 @@ const DeviceServiceStateWire = Schema.Struct({ ...deviceServiceStateFields, hosts: Schema.Array(MachineDeviceHostSummary), easHosts: Schema.optional(Schema.Array(EasDeviceHostSummary)), + simlockHosts: Schema.optional(Schema.Array(SimlockDeviceHostSummary)), }); const DeviceServiceStateDecoded = Schema.Struct({ ...deviceServiceStateFields, @@ -234,18 +300,22 @@ export const DeviceServiceState = DeviceServiceStateWire.pipe( typeof DeviceServiceStateDecoded.Type, typeof DeviceServiceStateWire.Type >({ - decode: ({ easHosts, ...state }) => ({ + decode: ({ easHosts, simlockHosts, ...state }) => ({ ...state, - hosts: [...state.hosts, ...(easHosts ?? [])], + hosts: [...state.hosts, ...(easHosts ?? []), ...(simlockHosts ?? [])], }), encode: (state) => ({ ...state, hosts: state.hosts.filter( - (host): host is typeof MachineDeviceHostSummary.Type => host.kind !== "eas", + (host): host is typeof MachineDeviceHostSummary.Type => + host.kind === "local" || host.kind === "ssh", ), easHosts: state.hosts.filter( (host): host is typeof EasDeviceHostSummary.Type => host.kind === "eas", ), + simlockHosts: state.hosts.filter( + (host): host is typeof SimlockDeviceHostSummary.Type => host.kind === "simlock", + ), }), }), ), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 8dd46fcc40de..26ccfa4ca819 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -111,6 +111,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinReorder: Schema.optionalKey(Schema.Boolean), threadActiveReorder: Schema.optionalKey(Schema.Boolean), threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + threadProjectFromScratch: Schema.optionalKey(Schema.Boolean), threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), threadVisitedTracking: Schema.optionalKey(Schema.Boolean), threadPullRequestLinking: Schema.optionalKey(Schema.Boolean), @@ -133,6 +134,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), + /** The desktop app switches update tracks when a server update request names one. */ + desktopAppChannelSwitch: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ @@ -160,6 +163,8 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({ platform: ExecutionEnvironmentPlatform, serverVersion: TrimmedNonEmptyString, orchestrationProtocolVersion: Schema.optionalKey(Schema.Int), + /** Highest migration this server's database holds; absent on older servers. */ + databaseMigration: Schema.optionalKey(Schema.Int), capabilities: ExecutionEnvironmentCapabilities, }); export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2af350525598..1fb055732a18 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -288,12 +288,21 @@ export interface DesktopUpdateReleaseNote { version: string; items: ReadonlyArray; totalItems: number; + /** ISO time from the update feed for the offered version, else `YYYY-MM-DD` from a prerelease version. */ + releasedAt?: string; + /** Generated summaries only: the highlights paragraph. `items` then holds the fixes. */ + intro?: string; + /** Generated summaries only, for example "275 changes from 22 contributors". */ + stats?: string; } export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ version: Schema.String, items: Schema.Array(Schema.String), totalItems: Schema.Number, + releasedAt: Schema.optionalKey(Schema.String), + intro: Schema.optionalKey(Schema.String), + stats: Schema.optionalKey(Schema.String), }); export const DesktopUpdateStateSchema = Schema.Struct({ @@ -1098,10 +1107,15 @@ export interface DesktopBridge { onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getWindowZoomFactor: () => number; getUpdateState: () => Promise; - setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; + /** `minimumVersion` is a track switch's checked release; nothing older is offered after. */ + setUpdateChannel: ( + channel: DesktopUpdateChannel, + minimumVersion?: string, + ) => Promise; checkForUpdate: () => Promise; downloadUpdate: () => Promise; - installUpdate: () => Promise; + /** With a version, installs only that downloaded build. */ + installUpdate: (expectedVersion?: string) => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; cliCommand: { getState: () => Promise; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ba0ce077a550..a3b3661ed119 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1351,6 +1351,14 @@ export const OrchestrationV2CheckpointFileSummary = Schema.Struct({ }); export type OrchestrationV2CheckpointFileSummary = typeof OrchestrationV2CheckpointFileSummary.Type; +/** Sent only when the wire carries a prefix of `files`; the totals cover the full list. */ +export const OrchestrationV2CheckpointFileTotals = Schema.Struct({ + count: NonNegativeInt, + additions: NonNegativeInt, + deletions: NonNegativeInt, +}); +export type OrchestrationV2CheckpointFileTotals = typeof OrchestrationV2CheckpointFileTotals.Type; + export const OrchestrationV2Checkpoint = Schema.Struct({ id: CheckpointId, threadId: ThreadId, @@ -1363,6 +1371,7 @@ export const OrchestrationV2Checkpoint = Schema.Struct({ ref: CheckpointRef, status: Schema.Literals(["ready", "missing", "error", "stale"]), files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), capturedAt: Schema.DateTimeUtc, }); export type OrchestrationV2Checkpoint = typeof OrchestrationV2Checkpoint.Type; @@ -1661,6 +1670,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ checkpointId: CheckpointId, scopeId: CheckpointScopeId, files: Schema.Array(OrchestrationV2CheckpointFileSummary), + filesTotal: Schema.optionalKey(OrchestrationV2CheckpointFileTotals), /** Same as the files of checkpoint `checkpointId`. */ filesOmitted: DedupedBodyMarker, }), @@ -3343,6 +3353,13 @@ const OrchestrationV2InternalCommand = Schema.Union([ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), }), + /** Moves a thread to a project rooted at the folder it already works in, so its cwd never changes. */ + Schema.Struct({ + type: Schema.Literal("thread.project.adopt-folder"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + }), Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, @@ -3581,6 +3598,8 @@ export const OrchestrationV2ThreadLaunchResult = Schema.Struct({ threadId: ThreadId, projection: OrchestrationV2ThreadProjection, resumed: Schema.Boolean, + /** The initial message's commit sequence. Older servers omit it. */ + sequence: Schema.optionalKey(NonNegativeInt), }); export type OrchestrationV2ThreadLaunchResult = typeof OrchestrationV2ThreadLaunchResult.Type; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 12934a7f8ed7..79e164b71e93 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -10,6 +10,7 @@ import { NonNegativeInt, PositiveInt, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, type UnknownUnionMember, @@ -527,6 +528,16 @@ export const ProjectCreateNewResult = Schema.Struct({ }); export type ProjectCreateNewResult = typeof ProjectCreateNewResult.Type; +export const ProjectCreateFromThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ProjectCreateFromThreadInput = typeof ProjectCreateFromThreadInput.Type; + +export const ProjectCreateFromThreadResult = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectCreateFromThreadResult = typeof ProjectCreateFromThreadResult.Type; + export class ProjectWriteFileError extends Schema.TaggedError()( "ProjectWriteFileError", { diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index e38d5fa6cf95..64111ab4742b 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -244,6 +244,7 @@ export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities export const PullRequestEditCapabilities = Schema.Struct({ changeRequest: Schema.Boolean, comment: Schema.Boolean, + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type; @@ -295,6 +296,7 @@ export const PullRequestViewerPermissions = Schema.Struct({ updateMethods: Schema.optional(ForwardCompatibleArray(PullRequestUpdateMethod)), labels: Schema.optional(Schema.Boolean), editChangeRequest: Schema.optional(Schema.Boolean), + maintainerEdits: Schema.optional(Schema.Boolean), }); export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type; @@ -589,6 +591,7 @@ export const PullRequestDetail = Schema.Struct({ autoMergeEnabled: Schema.optional(Schema.Boolean), autoMergeMethod: Schema.optional(PullRequestMergeMethod), workflowApprovalsRequired: Schema.optional(NonNegativeInt), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestDetail = typeof PullRequestDetail.Type; @@ -746,6 +749,7 @@ export const PullRequestUpdateInput = Schema.Struct({ ...PullRequestRef.fields, title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))), body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))), + maintainerCanModify: Schema.optional(Schema.Boolean), }); export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type; diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index bce626b0c366..5a004f93a2a4 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { HostPowerSnapshot } from "./background.ts"; -import { DesktopUpdateStateSchema } from "./ipc.ts"; +import { DesktopUpdateChannelSchema, DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; @@ -323,6 +323,9 @@ export const DesktopTelemetryRequestDesktopUpdate = Schema.Struct({ version: Schema.Literal(1), type: Schema.Literal("requestDesktopUpdate"), requestId: TrimmedNonEmptyString, + /** A track switch: move to this channel first, and install only `targetVersion`. */ + channel: Schema.optionalKey(DesktopUpdateChannelSchema), + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type DesktopTelemetryRequestDesktopUpdate = typeof DesktopTelemetryRequestDesktopUpdate.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index d89409ddba16..73dbd2ed1d13 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -231,6 +231,8 @@ import { import { ProjectCreateNewInput, ProjectCreateNewResult, + ProjectCreateFromThreadInput, + ProjectCreateFromThreadResult, ProjectEnsureScratchResult, ProjectListEntriesError, ProjectListEntriesInput, @@ -294,6 +296,9 @@ import { DeviceEasAccount, DeviceEasAccountError, DeviceEasAccountInput, + DeviceSimlockIdentity, + DeviceSimlockIdentityError, + DeviceSimlockIdentityInput, DeviceHostSummary, DeviceOpenInput, DeviceServiceState, @@ -414,6 +419,7 @@ export const WS_METHODS = { projectsMutate: "projects.mutate", projectsEnsureScratch: "projects.ensureScratch", projectsCreateNew: "projects.createNew", + projectsCreateFromThread: "projects.createFromThread", shellOpenInEditor: "shell.openInEditor", @@ -493,6 +499,7 @@ export const WS_METHODS = { deviceList: "device.list", deviceTestHost: "device.testHost", deviceEasAccount: "device.easAccount", + deviceSimlockIdentity: "device.simlockIdentity", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1330,6 +1337,12 @@ const WsProjectsCreateNewRpc = Rpc.make(WS_METHODS.projectsCreateNew, { error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), }); +const WsProjectsCreateFromThreadRpc = Rpc.make(WS_METHODS.projectsCreateFromThread, { + payload: ProjectCreateFromThreadInput, + success: ProjectCreateFromThreadResult, + error: Schema.Union([OrchestrationDispatchCommandError, EnvironmentAuthorizationError]), +}); + const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1675,6 +1688,12 @@ const WsDeviceEasAccountRpc = Rpc.make(WS_METHODS.deviceEasAccount, { error: Schema.Union([DeviceEasAccountError, EnvironmentAuthorizationError]), }); +const WsDeviceSimlockIdentityRpc = Rpc.make(WS_METHODS.deviceSimlockIdentity, { + payload: DeviceSimlockIdentityInput, + success: DeviceSimlockIdentity, + error: Schema.Union([DeviceSimlockIdentityError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -2095,6 +2114,7 @@ export const CoreWsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsEnsureScratchRpc, WsProjectsCreateNewRpc, + WsProjectsCreateFromThreadRpc, WsProjectsWriteFileRpc, WsProjectsMutateRpc, WsShellOpenInEditorRpc, @@ -2150,6 +2170,7 @@ export const CoreWsRpcGroup = RpcGroup.make( WsDeviceListRpc, WsDeviceTestHostRpc, WsDeviceEasAccountRpc, + WsDeviceSimlockIdentityRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 79c4afd1b62c..52c559067cfa 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -30,6 +30,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; +import { DesktopUpdateChannelSchema } from "./ipc.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -674,6 +675,8 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), threadFind: Schema.optionalKey(Schema.Boolean), threadFindProgressive: Schema.optionalKey(Schema.Boolean), + /** Settings patches may carry `secretProviders`; older servers drop the key without an error. */ + secretProviderSettings: Schema.optionalKey(Schema.Boolean), scratchWorkspaceRoot: Schema.optionalKey(TrimmedNonEmptyString), /** * Folder behind Home, the fleet-wide agent thread. Present only on servers a @@ -921,6 +924,10 @@ export class ServerProviderUpdateError extends Schema.TaggedError { @@ -117,6 +118,55 @@ describe("ServerSettings usage price overrides", () => { }); }); +describe("ServerSettings secret providers", () => { + const onePassword = (account: string) => ({ + kind: "one_password", + label: "Personal", + config: { account }, + }); + + it("keeps an unknown kind or a longer label from invalidating the rest of the settings", () => { + const openbao = { kind: "openbao", label: "V".repeat(65), config: { address: "https://x" } }; + const decoded = decodeServerSettings({ + worktreesDirectory: "/work", + secretProviders: { vault: openbao, personal: onePassword("my.1password.com") }, + }); + expect(decoded.worktreesDirectory).toBe("/work"); + expect(encodeServerSettings(decoded).secretProviders).toEqual({ + vault: openbao, + personal: onePassword("my.1password.com"), + }); + }); + + it("accepts slug ids and null removals but no id that is not a slug", () => { + expect( + decodeServerSettingsPatch({ + secretProviders: { "1password": onePassword("my.1password.com"), work: null }, + }).secretProviders, + ).toEqual({ "1password": onePassword("my.1password.com"), work: null }); + for (const id of ["", "My Vault", "a/b", "-vault"]) { + expect(() => + decodeServerSettingsPatch({ secretProviders: { [id]: onePassword("my.1password.com") } }), + ).toThrow(); + } + expect(() => + decodeServerSettingsPatch({ + secretProviders: { work: { ...onePassword("my.1password.com"), label: "V".repeat(65) } }, + }), + ).toThrow(); + }); + + it("rejects writes of an unknown kind or an account op could misread", () => { + const write = (entry: unknown) => () => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }); + for (const account of ["", "my account", "--help"]) { + expect(write(onePassword(account))).toThrow(); + } + expect(write({ kind: "one_password", label: "Personal" })).toThrow(); + expect(write({ kind: "openbao", label: "Vault", config: { address: "https://x" } })).toThrow(); + }); +}); + describe("custom model settings", () => { const capabilities = { optionDescriptors: [ @@ -325,6 +375,27 @@ it("validates remote device hosts and rejects ambiguous host ids", () => { expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); }); +describe("ServerSettings project overrides across versions", () => { + it("sends back an override key from a newer server when an older client edits the entry", () => { + const projectId = ProjectId.make("project-a"); + // As an older client reads a newer server's settings, with one key it does not know. + const entry = decodeServerSettings({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: { mode: "kept" } }, + }, + }).projectSettingsOverrides[projectId]; + + const sent = encodeServerSettingsPatch({ + projectSettingsOverrides: { [projectId]: { ...entry, defaultAutoPull: false } }, + }); + + expect(decodeServerSettingsPatch(sent).projectSettingsOverrides?.[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: { mode: "kept" }, + }); + }); +}); + describe("resolveProjectProviderInstanceEnabled", () => { const projectId = "project-a" as ProjectId; const instanceId = ProviderInstanceId.make("codex_work"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9730492cc2a2..797ba9855f2f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { EasDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; +import { EasDeviceHostConfigs, SimlockDeviceHostConfigs, SshDeviceHostConfigs } from "./device.ts"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -478,6 +478,13 @@ export const ClientSettingsSchema = Schema.Struct({ boringSidebarMultilineRows: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + // Retired Labs toggle. Still read as the default for the Usage item in threadDetailsItems. + threadDetailsUsageEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Sparse show/hide overrides for thread details card items. Missing ids use the client's + // default, so new items appear for everyone and unknown ids survive a downgrade. + threadDetailsItems: Schema.Record(Schema.String, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), // Opt-in for the removed legacy sidebar. Still read so those users land in // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -862,6 +869,76 @@ export const UsageLimitSourceConfig = Schema.Struct({ }); export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type; +// Final once shipped: older builds drop entries whose key fails here, and erase them on their next save. +// Unlike provider slugs it may start with a digit, so a provider named "1Password" fits. +const SecretProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), +); + +export const SecretProviderId = SecretProviderSlug.pipe(Schema.brand("SecretProviderId")); +export type SecretProviderId = typeof SecretProviderId.Type; + +export const SecretProviderKind = SecretProviderSlug.pipe(Schema.brand("SecretProviderKind")); +export type SecretProviderKind = typeof SecretProviderKind.Type; + +const ONE_PASSWORD_ACCOUNT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** + * Any value `op --account` accepts: shorthand, sign-in address, account ID, or user ID. + * It is passed to the CLI as an argument, so it can never start with `-`. + */ +export const OnePasswordAccount = Schema.String.check( + Schema.isMaxLength(253), + Schema.makeFilter((value: string) => + ONE_PASSWORD_ACCOUNT_PATTERN.test(value) + ? true + : "1Password account must be a shorthand, sign-in address, or ID with no spaces.", + ), +).pipe(Schema.brand("OnePasswordAccount")); +export type OnePasswordAccount = typeof OnePasswordAccount.Type; + +export const SecretProviderLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); + +export const OnePasswordSecretProviderConfig = Schema.Struct({ + account: OnePasswordAccount, +}); +export type OnePasswordSecretProviderConfig = typeof OnePasswordSecretProviderConfig.Type; + +/** Kinds decode their own config; unknown-kind envelopes round-trip verbatim across versions. */ +export const SecretProviderConfig = Schema.Struct({ + kind: SecretProviderKind, + label: TrimmedNonEmptyString, + config: Schema.optionalKey(Schema.Unknown), +}); +export type SecretProviderConfig = typeof SecretProviderConfig.Type; + +/** Kinds this build can resolve. Their `config` holds no credentials, so clients may read it. */ +const SECRET_PROVIDER_KIND_CONFIG_GUARDS: Readonly boolean>> = { + one_password: Schema.is(OnePasswordSecretProviderConfig), +}; + +export function isKnownSecretProviderKind(kind: string): boolean { + return Object.hasOwn(SECRET_PROVIDER_KIND_CONFIG_GUARDS, kind); +} + +// Limits that may change later are enforced on writes only, never on the stored envelope. +// Writes must be a kind this build knows with a config it accepts, so an older server +// rejects a newer kind instead of storing what it cannot check. +const SecretProviderConfigWrite = Schema.Struct({ + ...SecretProviderConfig.fields, + label: SecretProviderLabel, +}).check( + Schema.makeFilter((entry) => { + if (!isKnownSecretProviderKind(entry.kind)) { + return `Unsupported secret provider kind: ${entry.kind}.`; + } + return SECRET_PROVIDER_KIND_CONFIG_GUARDS[entry.kind]!(entry.config) + ? true + : `Invalid ${entry.kind} secret provider config.`; + }), +); + export const SourceControlHostSettings = Schema.Record(Schema.String, Schema.Unknown); export type SourceControlHostSettings = typeof SourceControlHostSettings.Type; @@ -1070,6 +1147,16 @@ export const ProjectSettingsOverrides = Schema.Struct({ } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; +// Whole-entry writers must round-trip unknown keys; keep the type closed to catch misspellings. +export const keepUnknownOverrideKeys = >( + entry: Entry, +): Schema.Codec< + Entry["Type"], + Entry["Encoded"], + Entry["DecodingServices"], + Entry["EncodingServices"] +> => Schema.StructWithRest(entry, [Schema.Record(Schema.String, Schema.Unknown)]); + /** True if null stores an override; otherwise null removes it. */ export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); @@ -1200,6 +1287,9 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + fallbackModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), ), @@ -1212,9 +1302,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(false)), ), - projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( - Schema.withDecodingDefault(Effect.succeed({})), - ), + projectSettingsOverrides: Schema.Record( + ProjectId, + keepUnknownOverrideKeys(ProjectSettingsOverrides), + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), /** Fold once so restarts cannot undo later UI resets. */ projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1224,6 +1315,9 @@ export const ServerSettings = Schema.Struct({ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Alpha: Simlock device hosts. Off, the server ignores `simlockDeviceHosts`. */ + simlockDeviceHostsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1352,6 +1446,9 @@ export const ServerSettings = Schema.Struct({ usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + secretProviders: Schema.Record(SecretProviderId, SecretProviderConfig).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), cursorKeychainUsageEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -1589,15 +1686,18 @@ export const ServerSettingsPatch = Schema.Struct({ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), autoApproveFullAccessPrompts: Schema.optionalKey(Schema.Boolean), + fallbackModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), /** Replaces each project's override set; null removes it. Omitted providerInstances retains its stored value. */ projectSettingsOverrides: Schema.optionalKey( Schema.Record( ProjectId, Schema.NullOr( - Schema.Struct({ - ...ProjectSettingsOverrides.fields, - sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), - }), + keepUnknownOverrideKeys( + Schema.Struct({ + ...ProjectSettingsOverrides.fields, + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + }), + ), ), ), ), @@ -1606,6 +1706,8 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), + simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs), + simlockDeviceHostsEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), compactIdleThreads: Schema.optionalKey(LegacyCompactIdleThreads), idleCompaction: Schema.optionalKey(IdleCompactionMode), @@ -1670,6 +1772,9 @@ export const ServerSettingsPatch = Schema.Struct({ usageLimitSources: Schema.optionalKey( Schema.Record(UsageLimitSourceId, Schema.NullOr(UsageLimitSourceConfig)), ), + secretProviders: Schema.optionalKey( + Schema.Record(SecretProviderId, Schema.NullOr(SecretProviderConfigWrite)), + ), cursorKeychainUsageEnabled: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey( @@ -1689,7 +1794,12 @@ export function requiredScopesForServerSettingsPatch( let changesSettings = false; for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue; - if (key === "providers" || key === "providerInstances" || key === "usageLimitSources") { + if ( + key === "providers" || + key === "providerInstances" || + key === "usageLimitSources" || + key === "secretProviders" + ) { changesProviders = true; } else { changesSettings = true; @@ -1771,6 +1881,8 @@ export const ClientSettingsPatch = Schema.Struct({ showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), boringSidebarMultilineRows: Schema.optionalKey(Schema.Boolean), + threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), + threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 26a5319c1258..71de22deb5d4 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -210,7 +210,13 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined; const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, - capabilities: AcpProviderCapabilitiesV2, + // Cancelling a Copilot prompt also kills the background agents it started. + capabilities: isCopilot + ? { + ...AcpProviderCapabilitiesV2, + turns: { ...AcpProviderCapabilitiesV2.turns, supportsSteeringByInterruptRestart: false }, + } + : AcpProviderCapabilitiesV2, customModels: readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug), promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b88e31d20394..13f0cbbc240d 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -76,6 +76,7 @@ import { parsePermissionRequest, parseSessionUpdateEvent, toolCallVisibleOutputChanged, + withTrackedPermissionToolCall, type AcpPlanUpdate, type AcpAgentTerminalState, type AcpSessionModeState, @@ -223,6 +224,7 @@ export interface AcpAdapterV2Flavor { readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; }) => Effect.Effect, EffectAcpErrors.AcpError>; readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure; + readonly requiresSignIn?: (cause: unknown) => boolean; /** * Runs before attachment paths, T3 instructions and native slash-command detection, * so a rewrite can turn the user's text into a native command. @@ -287,7 +289,7 @@ export interface AcpAdapterV2Flavor { request: EffectAcpSchema.ReadTextFileRequest, cwd: string | null, ) => Effect.Effect; - readonly writeTextFile: ( + readonly writeTextFile?: ( request: EffectAcpSchema.WriteTextFileRequest, cwd: string | null, ) => Effect.Effect; @@ -1992,7 +1994,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( clientCapabilities: { fs: { readTextFile: flavor.clientFileSystem !== undefined, - writeTextFile: flavor.clientFileSystem !== undefined, + writeTextFile: flavor.clientFileSystem?.writeTextFile !== undefined, }, terminal: clientTerminals !== undefined, elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) }, @@ -5957,11 +5959,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)), ), ); - yield* targetRuntime.handleWriteTextFile((request) => - clientPolicyContext.pipe( - Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)), - ), - ); + const writeTextFile = clientFileSystem.writeTextFile; + if (writeTextFile !== undefined) { + yield* targetRuntime.handleWriteTextFile((request) => + clientPolicyContext.pipe( + Effect.flatMap(({ policy }) => writeTextFile(request, policy.cwd)), + ), + ); + } } if (handlerOptions.mcp !== false) { yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge); @@ -5974,8 +5979,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification), ); } - yield* targetRuntime.handleRequestPermission((params, requestContext) => + yield* targetRuntime.handleRequestPermission((nativeParams, requestContext) => Effect.gen(function* () { + const trackedTurn = yield* Ref.get(activeTurn); + const params = + trackedTurn !== null && nativeParams.sessionId === (yield* Ref.get(activeSessionId)) + ? withTrackedPermissionToolCall( + nativeParams, + trackedTurn.tools.get(nativeParams.toolCall.toolCallId), + ) + : nativeParams; const transportRequestId = requestContext.requestId; const permissionQuestion = flavor.extractPermissionQuestion?.(params); if (permissionQuestion !== undefined) { @@ -8543,6 +8556,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new ProviderAdapter.ProviderAdapterOpenSessionError({ driver, providerSessionId: input.providerSessionId, + ...(flavor.requiresSignIn?.(cause) === true ? { requiresSignIn: true } : {}), cause, }), ), diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 70682ec4824a..e60671c775d5 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -17,6 +17,7 @@ import { sessionUpdateIsReplay, syntheticLoadSessionResponseFromInitialize, toolCallProgressLength, + withTrackedPermissionToolCall, type AcpToolCallState, } from "./runtimeModel.ts"; @@ -432,6 +433,40 @@ describe("AcpRuntimeModel", () => { }); }); + it("keeps an explicit permission request kind over the tracked tool call's kind", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: "read" }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request)).toMatchObject({ kind: "read", detail: "git status" }); + }); + + it("keeps explicitly cleared permission request fields over the tracked tool call", () => { + const request = withTrackedPermissionToolCall( + { + sessionId: "session-1", + options: [{ optionId: "allow-once", name: "Allow once", kind: "allow_once" }], + toolCall: { toolCallId: "tool-1", kind: null, title: null, rawInput: null }, + }, + { + toolCallId: "tool-1", + kind: "execute", + data: { toolCallId: "tool-1", title: "git status", rawInput: { command: "git status" } }, + }, + ); + + expect(parsePermissionRequest(request).kind).toBe("unknown"); + }); + it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => { const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join( "\n", diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 87f24c5afdba..43fa2bf758e2 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1179,6 +1179,29 @@ export function embeddedTerminalIdsFromSessionUpdate( return terminalIds.length === 0 ? undefined : { toolCallId: update.toolCallId, terminalIds }; } +/** ACP lets a permission request name its tool call by ID only; omitted (not null) fields come from the tracked call. */ +export function withTrackedPermissionToolCall( + params: EffectAcpSchema.RequestPermissionRequest, + tracked: AcpToolCallState | undefined, +): EffectAcpSchema.RequestPermissionRequest { + if (tracked === undefined) return params; + const { title, kind, rawInput } = params.toolCall; + return { + ...params, + toolCall: { + ...params.toolCall, + ...(title === undefined && typeof tracked.data.title === "string" + ? { title: tracked.data.title } + : {}), + // Unrecognised kinds map to command approvals, so only a tracked execute kind may grant execution. + ...(kind === undefined && tracked.kind === "execute" ? { kind: "execute" as const } : {}), + ...(rawInput === undefined && tracked.data.rawInput !== undefined + ? { rawInput: tracked.data.rawInput } + : {}), + }, + }; +} + export function parsePermissionRequest( params: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionRequest { diff --git a/packages/provider-acp/src/server/stderr.test.ts b/packages/provider-acp/src/server/stderr.test.ts index f582f47984ff..fdf3c68f97e4 100644 --- a/packages/provider-acp/src/server/stderr.test.ts +++ b/packages/provider-acp/src/server/stderr.test.ts @@ -50,4 +50,17 @@ describe("AcpStderr", () => { expect(excerpt).not.toContain("dXNlcjpwYXNz"); expect(excerpt).not.toContain("ant-api-key-value"); }); + + it("redacts the whole key when its body contains underscores", () => { + const excerpt = sanitizeAcpStderrExcerpt( + [ + "anthropic=sk-ant-api03-AbCdEf_GhIjKlMnOpQr-StUvWx_yz0123456789-AA", + "openai=sk-proj-AbCdEfGhIj_KlMnOpQrStUv-WxYz_0123456789", + ].join("\n"), + "/Users/ada", + {}, + ); + + expect(excerpt).toBe("anthropic=[redacted]\nopenai=[redacted]"); + }); }); diff --git a/packages/provider-acp/src/server/stderr.ts b/packages/provider-acp/src/server/stderr.ts index f988f2d632d9..92ae01a4c27e 100644 --- a/packages/provider-acp/src/server/stderr.ts +++ b/packages/provider-acp/src/server/stderr.ts @@ -6,7 +6,7 @@ const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._\-+=/]+/gi; const BASIC_AUTH_PATTERN = /\bAuthorization:\s*Basic\s+\S+/gi; const API_KEY_HEADER_PATTERN = /\bx-api-key:\s*\S+/gi; const SECRET_TOKEN_PATTERN = - /\b(?:sk-[A-Za-z0-9][A-Za-z0-9-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; + /\b(?:sk-[A-Za-z0-9][A-Za-z0-9_-]{7,}|ghp_[A-Za-z0-9]+|xox[a-zA-Z]-[A-Za-z0-9-]+)\b/g; export function appendAcpStderrTail(current: string, chunk: string): string { const next = `${current}${chunk}`; diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 346a7065a685..904a0b5dd376 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -184,6 +184,7 @@ export class ProviderAdapterOpenSessionError extends Schema.TaggedError { assert.notInclude(failure.message, "sk-abcdefghijklmnop"); }); +it("redacts credentials whose key names end in a credential word", () => { + const failure = makeProviderFailure({ + code: '{"refresh_token":"code value"}', + message: + 'oauth refresh failed: client_secret=client-value refresh_token=refresh-value {"client_secret":"json-value"} clientSecret: camel-value X-Auth-Token: header-value max_tokens=4096', + }); + + assert.equal(failure.code, '{"refresh_token":"[REDACTED]"}'); + assert.equal( + failure.message, + 'oauth refresh failed: client_secret=[REDACTED] refresh_token=[REDACTED] {"client_secret":"[REDACTED]"} clientSecret: [REDACTED] X-Auth-Token: [REDACTED] max_tokens=4096', + ); +}); + it("does not split a surrogate pair at the truncation boundary", () => { const failure = makeProviderFailure({ message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}๐Ÿš€tail`, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index b0ac1ed8b938..2dd418ea2dfa 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -194,11 +194,11 @@ function redactProviderFailureText(value: string): string { .replace(/\bhttps?:\/\/[^\s<>"']+/giu, redactUrl) .replace(/\b(Bearer|Basic)\s+[^\s,;]+/giu, "$1 [REDACTED]") .replace( - /(["'](?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, + /(["'][\w-]*(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)["']\s*:\s*["'])[^"']*(["'])/giu, "$1[REDACTED]$2", ) .replace( - /(\b(?:access[_-]?token|api[_-]?key|authorization|credential|password|secret|token)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + /((? boolean; /** The updater cannot detect installs outside `isCommandPath`, so leave those manual-only. */ readonly requiresCommandPath?: boolean; - readonly env?: NodeJS.ProcessEnv; + readonly env?: + | NodeJS.ProcessEnv + | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv); } | null; } @@ -446,6 +448,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( } const nativeUpdate = definition.nativeUpdate; + const nativeEnv = nativeUpdate?.env; const native = nativeUpdate ? makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -454,7 +457,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( updateArgs: nativeUpdate.args, updateLockKey: `${definition.provider}-native`, platform: context.platform, - ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), + ...(nativeEnv + ? { env: typeof nativeEnv === "function" ? nativeEnv(context) : nativeEnv } + : {}), }) : manual; // A native-looking launcher can still delegate to a mise-owned install. diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index 668502f93e43..7794107824c9 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,6 +6,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cache from "effect/Cache"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -138,6 +139,116 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect("invalidates cached usage and queues one refresh behind an in-flight refresh", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const probeStarted = yield* Deferred.make(); + const releaseProbe = yield* Deferred.make(); + const cache = yield* Cache.makeWith( + () => + Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(probeStarted, undefined); + yield* Deferred.await(releaseProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { + checkedAt: refreshedSnapshot.checkedAt, + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: count === 1 ? 20 : 100, + }, + ], + }, + } satisfies ServerProvider; + }), + { capacity: 1, timeToLive: () => "5 minutes" }, + ); + const warmed = yield* Cache.get(cache, "claude"); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.die("usage refresh must not resolve maintenance"), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(warmed), + checkProvider: Cache.get(cache, "claude"), + invalidateUsageCache: Cache.invalidateAll(cache), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + assert.strictEqual(yield* Ref.get(calls), 1); + const refresh = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(probeStarted); + const queued = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* provider.refreshUsage!; + assert.strictEqual(yield* Ref.get(calls), 2); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 20); + yield* Deferred.succeed(releaseProbe, undefined); + yield* Fiber.join(refresh); + yield* Fiber.join(queued); + assert.strictEqual(yield* Ref.get(calls), 3); + assert.strictEqual((yield* provider.getSnapshot).usageLimits?.windows[0]?.usedPercent, 100); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); + it.effect("keeps the waiting usage refresh's claim when the running one is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const usageProbeStarted = yield* Deferred.make(); + const statusProbeStarted = yield* Deferred.make(); + const releaseStatusProbe = yield* Deferred.make(); + const provider = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed(maintenanceCapabilities), + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Effect.gen(function* () { + const count = yield* Ref.updateAndGet(calls, (count) => count + 1); + if (count === 2) { + yield* Deferred.succeed(usageProbeStarted, undefined); + return yield* Effect.never; + } + if (count === 3) { + yield* Deferred.succeed(statusProbeStarted, undefined); + yield* Deferred.await(releaseStatusProbe); + } + return { + ...refreshedSnapshot, + usageLimits: { checkedAt: refreshedSnapshot.checkedAt, windows: [] }, + } satisfies ServerProvider; + }), + refreshOnInterval: false, + }); + yield* provider.streamChanges.pipe(Stream.take(1), Stream.runDrain); + const running = yield* provider.refreshUsage!.pipe(Effect.forkChild); + yield* Deferred.await(usageProbeStarted); + const status = yield* provider.refresh.pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.interrupt(running); + yield* Deferred.await(statusProbeStarted); + const extra = yield* provider.refreshUsage!.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(releaseStatusProbe, undefined); + yield* Fiber.join(status); + yield* Fiber.join(waiting); + yield* Fiber.join(extra); + assert.strictEqual(yield* Ref.get(calls), 4); + }), + ).pipe(Effect.provide(layerAlwaysRunTest)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/packages/provider-core/src/server/managedProvider.ts b/packages/provider-core/src/server/managedProvider.ts index 4c417361ff51..90536ad4d021 100644 --- a/packages/provider-core/src/server/managedProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -45,6 +45,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; readonly checkProvider: Effect.Effect; + readonly invalidateUsageCache?: Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -61,6 +62,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( > { const host = yield* ProviderHost.ProviderHost; const refreshSemaphore = yield* Semaphore.make(1); + const usageRefreshQueued = yield* Ref.make(null); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), PubSub.shutdown, @@ -217,6 +219,38 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* applySnapshot(nextSettings, { forceRefresh: true }); }); + const refreshUsage = Effect.fn("ManagedServerProvider.refreshUsage")(function* () { + const { snapshot } = yield* Ref.get(snapshotStateRef); + if ( + !snapshot.enabled || + !snapshot.usageLimits || + snapshot.usageLimits.unavailable?.reason === "unsupported" + ) + return; + // An in-flight refresh may have read cached usage, so one refresh waits behind it. + const claim = Symbol(); + const release = Ref.update(usageRefreshQueued, (queued) => (queued === claim ? null : queued)); + yield* Effect.uninterruptibleMask((restore) => + Ref.modify(usageRefreshQueued, (queued) => [queued !== null, queued ?? claim]).pipe( + Effect.flatMap((held) => + held + ? Effect.void + : restore( + refreshSemaphore.withPermits(1)( + release.pipe( + Effect.andThen(input.invalidateUsageCache ?? Effect.void), + Effect.andThen(input.getSettings), + Effect.flatMap((settings) => + applySnapshotBase(settings, { forceRefresh: true }), + ), + ), + ), + ).pipe(Effect.onInterrupt(() => release)), + ), + ), + ); + }); + const shouldRefreshOnInterval = Effect.gen(function* () { const state = yield* Ref.get(snapshotStateRef); // A disabled instance has nothing to probe; a periodic check would only @@ -305,6 +339,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( .resolveMaintenance({ fresh: true }) .pipe(Effect.andThen(refreshSnapshot), Effect.tapError(Effect.logError), Effect.orDie), applyUsageLimits, + refreshUsage: refreshUsage(), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/packages/provider-core/src/server/snapshot.ts b/packages/provider-core/src/server/snapshot.ts index c9c877d29fe2..88f2c1f11384 100644 --- a/packages/provider-core/src/server/snapshot.ts +++ b/packages/provider-core/src/server/snapshot.ts @@ -1,4 +1,8 @@ -import type { ProviderUsageLimitsUpdate, ServerProvider } from "@t3tools/contracts"; +import type { + ProviderUsageLimitsUpdate, + ServerProvider, + ServerSettingsError, +} from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "./maintenance.ts"; @@ -10,6 +14,7 @@ export interface ManagedServerProvider { }) => Effect.Effect; readonly getSnapshot: Effect.Effect; readonly refresh: Effect.Effect; + readonly refreshUsage?: Effect.Effect; readonly streamChanges: Stream.Stream; /** Merge windows by ID; updates with no usable window leave the snapshot untouched. */ readonly applyUsageLimits: ( diff --git a/packages/provider-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index f83a0b376233..be8c4bf98aa5 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -82,6 +82,8 @@ export function makeSubagentChildThread(input: { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, // A guest link belongs to the thread it was made for. guestShare: null, lastVisitedAt: null, diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 7f0decb49af3..5986d09506d0 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -43,6 +43,21 @@ export function cursorSdkFailureMessage(error: CursorAgentSdkRunnerError): strin : `Cursor Agent SDK ${error.method} failed: ${reason}`; } +// The SDK maps backend details to codes like PRO_USER_USAGE_LIMIT or RATE_LIMITED, without a status. +const CURSOR_USAGE_LIMIT_CODE = + /^(?:resource_exhausted|rate_limited(?:_changeable)?|\w*_usage_limit|\w*_rate_limit(?:_exceeded)?)$/i; + +export function isCursorUsageLimitError(cause: unknown): boolean { + const error = isCursorAgentSdkRunnerError(cause) ? cause.cause : cause; + if (typeof error !== "object" || error === null) return false; + const code = Reflect.get(error, "code"); + return ( + Reflect.get(error, "name") === "RateLimitError" || + Reflect.get(error, "status") === 429 || + (typeof code === "string" && CURSOR_USAGE_LIMIT_CODE.test(code)) + ); +} + const GENERIC_SDK_ERROR_CODES = new Set(["", "error", "unknown"]); function sdkErrorReason(cause: unknown): string | undefined { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 5e7093b67ae2..062089fffa0c 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -214,6 +214,15 @@ describe("CursorAdapterV2", () => { ); it.effect.each([ + { + reason: "the SDK's detailed plan usage limit code", + wait: Effect.succeed({ + id: "native-cursor-run", + status: "error" as const, + error: { message: "You've hit your usage limit", code: "PRO_USER_USAGE_LIMIT" }, + }), + failure: { class: "usage_limit", message: "You've hit your usage limit" }, + }, { reason: "the error Cursor's run ended with", wait: Effect.succeed({ @@ -232,7 +241,7 @@ describe("CursorAdapterV2", () => { }, }, { - reason: "the SDK error that broke the run", + reason: "the SDK rate limit that broke the run", wait: Effect.fail( new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "run.wait", @@ -243,7 +252,7 @@ describe("CursorAdapterV2", () => { }), ), failure: { - class: "transport_error", + class: "usage_limit", message: "Cursor Agent SDK run.wait failed: Too many requests (resource_exhausted, HTTP 429)", }, @@ -1140,6 +1149,227 @@ describe("CursorAdapterV2", () => { ), ); + it.effect("projects subagent directory trees Cursor serialized without empty lists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-task-ls-" }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-task-ls-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + // The SDK serializes subagent steps with protobuf toJson(), which omits empty repeated fields. + const conversationSteps = [ + { + toolCall: { + toolCallId: "tool:ls", + lsToolCall: { + args: { path: workspace }, + result: { + success: { + directoryTreeRoot: { + absPath: workspace, + childrenWereProcessed: true, + childrenDirs: [ + { + absPath: path.join(workspace, "src"), + childrenFiles: [{ name: "index.ts" }], + childrenWereProcessed: true, + numFiles: 1, + }, + ], + }, + }, + }, + }, + }, + }, + { + toolCall: { + toolCallId: "tool:lints", + readLintsToolCall: { args: {}, result: { success: {} } }, + }, + }, + { + toolCall: { + toolCallId: "tool:lints-clean-file", + readLintsToolCall: { + args: {}, + result: { success: { fileDiagnostics: [{ path: "src/clean.ts" }] } }, + }, + }, + }, + { + toolCall: { + toolCallId: "tool:mcp", + mcpToolCall: { + args: { providerIdentifier: "docs", toolName: "lookup", args: { query: "ls" } }, + result: { success: {} }, + }, + }, + }, + ]; + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-task-ls", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + const args = { + description: "Explore", + prompt: "List the workspace.", + subagentType: { kind: "unspecified" as const }, + agentId: "cursor-task-agent", + mode: "unspecified" as const, + }; + yield* input.onDelta!({ + type: "tool-call-started", + modelCallId: "model-call", + callId: "task-call", + toolCall: { type: "task", args }, + }); + yield* input.onDelta!({ + type: "tool-call-completed", + modelCallId: "model-call", + callId: "task-call", + toolCall: { + type: "task", + args, + result: { + status: "success", + value: { + conversationSteps, + isBackground: false, + backgroundReason: "unspecified", + }, + }, + }, + }); + return { + agentId: "native-cursor-task-ls", + runId: "native-cursor-run", + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }).pipe(Effect.orDie), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-task-ls-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-task-ls-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-task-ls-attempt"), + rootNodeId: NodeId.make("cursor-task-ls-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-task-ls-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor subagent ls", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make("cursor-task-ls-message"), + createdBy: "user", + creationSource: "web", + text: "List the workspace with a subagent.", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = events.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "completed"); + const childThreadId = events.findLast((event) => event.type === "subagent.updated")?.subagent + .childThreadId; + assert.isDefined(childThreadId); + assert.deepEqual( + events.flatMap((event): ReadonlyArray => { + if (event.type !== "turn_item.updated" || event.turnItem.threadId !== childThreadId) { + return []; + } + const item = event.turnItem; + return item.type === "file_search" + ? [{ status: item.status, pattern: item.pattern, results: item.results ?? [] }] + : item.type === "dynamic_tool" + ? [{ status: item.status, toolName: item.toolName, output: item.output }] + : []; + }), + [ + { + status: "completed", + pattern: workspace, + results: [{ fileName: path.join(workspace, "src", "index.ts") }], + }, + { status: "completed", pattern: "", results: [] }, + { status: "completed", pattern: "", results: [] }, + { + status: "completed", + toolName: "mcp__docs__lookup", + output: { content: [], isError: false }, + }, + ], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("names a custom model Cursor's account catalog rejects", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 1d98c4523ffe..72aca919b25f 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -625,6 +625,29 @@ function nestedGrepWorkspaceResults(success: Record): Record { + return Array.isArray(value) ? value : []; +} + +function nestedLsDirectory(rawNode: unknown): CursorLsDirectoryNode { + const node = unknownRecord(rawNode); + return { + absPath: typeof node?.absPath === "string" ? node.absPath : "", + childrenDirs: nestedArray(node?.childrenDirs).map(nestedLsDirectory), + childrenFiles: nestedArray(node?.childrenFiles).flatMap((rawFile) => { + const name = unknownRecord(rawFile)?.name; + return typeof name === "string" ? [{ name }] : []; + }), + childrenWereProcessed: node?.childrenWereProcessed === true, + fullSubtreeExtensionCounts: Object.fromEntries( + Object.entries(unknownRecord(node?.fullSubtreeExtensionCounts) ?? {}).filter( + (entry): entry is [string, number] => typeof entry[1] === "number", + ), + ), + numFiles: typeof node?.numFiles === "number" ? node.numFiles : 0, + }; +} + export function nestedToolCallFromEnvelope( envelope: Record, ): { readonly callId: string; readonly toolCall: ToolCall } | undefined { @@ -730,6 +753,66 @@ export function nestedToolCallFromEnvelope( } as unknown as ToolCall, }; } + case "lsToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "ls", + args: { path: typeof args.path === "string" ? args.path : "" }, + result: nestedToolResult(call.result, (value) => ({ + directoryTreeRoot: nestedLsDirectory(value.directoryTreeRoot), + })), + } as unknown as ToolCall, + }; + case "readLintsToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "readLints", + args: { + paths: nestedArray(args.paths).filter( + (path): path is string => typeof path === "string", + ), + }, + result: nestedToolResult(call.result, (value) => ({ + fileDiagnostics: nestedArray(value.fileDiagnostics).map((rawFile) => { + const file = unknownRecord(rawFile); + return { + path: typeof file?.path === "string" ? file.path : "", + diagnostics: nestedArray(file?.diagnostics).flatMap((rawDiagnostic) => { + const diagnostic = unknownRecord(rawDiagnostic); + return diagnostic === undefined + ? [] + : [ + { + ...diagnostic, + message: typeof diagnostic.message === "string" ? diagnostic.message : "", + }, + ]; + }), + }; + }), + totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, + totalDiagnostics: + typeof value.totalDiagnostics === "number" ? value.totalDiagnostics : 0, + })), + } as unknown as ToolCall, + }; + case "mcpToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "mcp", + args, + result: nestedToolResult(call.result, (value) => ({ + content: nestedArray(value.content).flatMap((rawPart) => { + const part = unknownRecord(rawPart); + return part === undefined ? [] : [part]; + }), + isError: value.isError === true, + })), + } as unknown as ToolCall, + }; default: { const type = wrapperName.slice(0, -"ToolCall".length); return { @@ -2423,7 +2506,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( cause: result.error, // makeProviderFailure never reads a cause's own text. message: result.error?.message, - class: "provider_error", + class: CursorAgentSdk.isCursorUsageLimitError(result.error) + ? "usage_limit" + : "provider_error", }) : makeProviderFailure({ cause: transportFailure, @@ -2449,7 +2534,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message: CursorAgentSdk.isCursorAgentSdkRunnerError(cause) ? CursorAgentSdk.cursorSdkFailureMessage(cause) : undefined, - class: "transport_error", + class: CursorAgentSdk.isCursorUsageLimitError(cause) + ? "usage_limit" + : "transport_error", }), }), }); diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 759545fff0d1..52f7590e63f4 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Exercise HTTP authentication with the real SDK. +import * as NodeHttp from "node:http"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; @@ -50,6 +55,7 @@ import { import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeOpenCodeSettings = Schema.decodeEffect(OpenCodeSettings); const OPEN_CODE_TEST_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ serverUrl: "http://test.invalid", }); @@ -240,6 +246,51 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect("classifies APIError 429 as a usage limit", () => + Effect.gen(function* () { + const nativeEvents = asyncEventStream(); + const harness = yield* makeOpenCodeRuntimeHarness("usage-limit", "root", { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + session: { + create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }); + yield* harness.startTurn(); + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Effect.promise(() => + nativeEvents.push({ + type: "session.error", + properties: { + sessionID: "root", + error: { + name: "APIError", + data: { + message: "Provider rejected request", + statusCode: 429, + isRetryable: true, + }, + }, + }, + }), + ); + const terminal = (yield* Fiber.join(received)).find( + (event) => event.type === "turn.terminal", + ); + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.failure?.class, "usage_limit"); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect("applies a changed runtime policy to the session on resume and turn start", () => Effect.gen(function* () { const updates: Array<{ sessionID: string; permission: unknown }> = []; @@ -2626,3 +2677,123 @@ it.effect.each([false, true])( Effect.scoped, ), ); + +it.live.each([true, false])("opens an authenticated OpenCode session (external=%s)", (external) => + Effect.gen(function* () { + const password = external ? "settings-password" : "environment-password"; + const requests: string[] = []; + let eventConnected!: () => void; + const eventRequest = new Promise((resolve) => { + eventConnected = resolve; + }); + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise>((resolve) => { + const server = NodeHttp.createServer((request, response) => { + if ( + request.headers.authorization !== + `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` + ) { + response.writeHead(401).end("Unauthorized"); + return; + } + const url = new URL(request.url!, "http://localhost"); + requests.push(url.pathname); + if (url.pathname === "/event") { + response.writeHead(200, { "Content-Type": "text/event-stream" }); + response.write(": connected\n\n"); + eventConnected(); + return; + } + response.writeHead(200, { "Content-Type": "application/json" }); + response.end( + JSON.stringify( + url.pathname.endsWith("/children") + ? [] + : url.pathname === "/session" + ? { id: "native-auth", time: { created: 1, updated: 1 } } + : { healthy: true, version: "1.14.19" }, + ), + ); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + assert.isNotNull(address); + if (!address || typeof address === "string") return; + const url = `http://127.0.0.1:${address.port}`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let managedStarts = 0; + const runtimeLayer = OpenCodeRuntime.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + assert.isTrue(ChildProcess.isStandardCommand(command)); + if (!ChildProcess.isStandardCommand(command)) return Effect.die("Unexpected pipeline"); + assert.include(command.args, "serve"); + assert.equal(command.options.env?.OPENCODE_SERVER_PASSWORD, password); + managedStarts += 1; + return spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "-e", + `console.log("server listening on " + process.argv[1]); setInterval(() => {}, 1000)`, + url, + ], + command.options, + ), + ); + }), + ), + ), + Layer.provide(OpenCodeServerLedger.layerTest), + Layer.provideMerge(NodeServices.layer), + ); + const instanceId = ProviderInstanceId.make("opencode-auth"); + const adapter = yield* makeOpenCodeAdapterV2({ + instanceId, + settings: yield* decodeOpenCodeSettings({ + serverUrl: external ? url : "", + ...(external ? { serverPassword: password } : {}), + }), + environment: { OPENCODE_SERVER_PASSWORD: "environment-password" }, + }).pipe(Effect.provide(runtimeLayer)); + const session = yield* adapter.openSession({ + threadId: ThreadId.make("thread-opencode-auth"), + providerSessionId: ProviderSessionId.make("session-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + yield* session.ensureThread({ + threadId: ThreadId.make("thread-opencode-auth"), + modelSelection: { instanceId, model: "anthropic/claude-sonnet", options: [] }, + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + }); + assert.include(requests, "/session"); + yield* Effect.promise(() => eventRequest); + assert.include(requests, "/event"); + assert.include(requests, "/global/health"); + assert.equal(managedStarts, external ? 0 : 1); + }).pipe( + Effect.scoped, + Effect.provide([ + NodeServices.layer, + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + IdAllocator.layer, + McpProviderSessions.layer, + ]), + ), +); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index ef85a7e70b29..7762c3b64503 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -936,13 +936,16 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function binaryPath: options.settings.binaryPath, directory: cwd, serverUrl: options.settings.serverUrl, + ...(options.settings.serverPassword + ? { serverPassword: options.settings.serverPassword } + : {}), environment: options.environment, }); const client = runtime.createOpenCodeSdkClient({ baseUrl: connection.url, directory: cwd, - ...(connection.external && options.settings.serverPassword - ? { serverPassword: options.settings.serverPassword } + ...(connection.serverPassword !== undefined + ? { serverPassword: connection.serverPassword } : {}), }); @@ -2643,6 +2646,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function (state): state is OpenCodeThreadState => state !== undefined, ); const message = openCodeErrorMessage(event); + const error = event.properties.error; + const usageLimit = error?.name === "APIError" && error.data.statusCode === 429; if ( !isMessageAbortedError(event) && (event.properties.sessionID === undefined || @@ -2659,8 +2664,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function { failure: makeProviderFailure({ message, - code: event.properties.error?.name ?? null, - class: "provider_error", + code: error?.name ?? null, + class: usageLimit ? "usage_limit" : "provider_error", }), threadDisposition: event.properties.sessionID === undefined ? "broken" : "reusable", diff --git a/packages/shared/src/authScopeOptions.ts b/packages/shared/src/authScopeOptions.ts index d443e50326d2..9a2261a09f3e 100644 --- a/packages/shared/src/authScopeOptions.ts +++ b/packages/shared/src/authScopeOptions.ts @@ -104,3 +104,19 @@ export const AUTH_SCOPE_OPTIONS: ReadonlyArray<{ description: "Change managed tunnel connectivity.", }, ]; + +/** Shown instead of the granted scopes while they are unknown, so every surface says the same thing. */ +export function sessionPermissionsUnavailableMessage(input: { + readonly connected: boolean; + /** The check errored; the client retries it while connected. */ + readonly failed: boolean; + /** The environment answered that this session is not signed in; retrying will not change that. */ + readonly rejected: boolean; +}): string { + if (!input.connected) return "Connect to this environment to view this sessionโ€™s permissions."; + if (input.rejected) + return "This environment did not accept your session. Reconnect to try again."; + return input.failed + ? "Could not verify your permissions. Retrying automatically." + : "Checking permissionsโ€ฆ"; +} diff --git a/packages/shared/src/changedFileKind.ts b/packages/shared/src/changedFileKind.ts index 9fd5c0c7edc7..01cbe899b26f 100644 --- a/packages/shared/src/changedFileKind.ts +++ b/packages/shared/src/changedFileKind.ts @@ -67,6 +67,11 @@ const NOT_DOCS_NAME = /^(requirements.*|constraints.*|CMakeLists|robots|llms)\.t * output count as generated, and test support (mocks, fixtures, snapshots) is kept apart from the * tests themselves, and prose (Markdown, plain text) is its own kind, so a reviewer can see how much of a change is logic. */ +export function isTestChangedFile(path: string): boolean { + const kind = classifyChangedFile(path); + return kind === "tests" || kind === "mocks"; +} + export function classifyChangedFile(path: string): ChangedFileKind { const segments = path.replaceAll("\\", "/").split("/"); const name = segments.pop() ?? ""; diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index e4fdfe3c2dba..d4006e3bf4eb 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -54,10 +54,41 @@ describe("parseCliArgs", () => { }); }); - it("ignores bare -- with no flag name", () => { + it("accepts a trailing -- without operands", () => { expect(parseCliArgs("--")).toEqual({ flags: {}, positionals: [] }); }); + it.each([ + { args: "--effort low -- --effort high --chrome" }, + { args: ["--effort", "low", "--", "--effort", "high", "--chrome"] }, + ])("ends option parsing at -- for $args", ({ args }) => { + expect(parseCliArgs(args)).toEqual({ + flags: { effort: "low" }, + positionals: ["--effort", "high", "--chrome"], + }); + }); + + it("preserves all operands after --, including empty strings and another --", () => { + expect(parseCliArgs(["--", "", "--", "--model=example", "-x"])).toEqual({ + flags: {}, + positionals: ["", "--", "--model=example", "-x"], + }); + }); + + it("ends option parsing with explicit boolean flags too", () => { + expect(parseCliArgs(["--chrome", "--", "--verbose"], { booleanFlags: ["chrome"] })).toEqual({ + flags: { chrome: null }, + positionals: ["--verbose"], + }); + }); + + it("keeps -- inside an equals-form option value", () => { + expect(parseCliArgs("--append-system-prompt=-- --chrome")).toEqual({ + flags: { "append-system-prompt": "--", chrome: null }, + positionals: [], + }); + }); + it("boolean flag does not consume next token as value", () => { expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( { diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index e6e348797a90..253b7191ed83 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -77,9 +77,13 @@ export function parseCliArgs( for (let i = 0; i < tokens.length; i++) { const token = tokens[i]!; + if (token === "--") { + positionals.push(...tokens.slice(i + 1)); + break; + } + if (token.startsWith("--")) { const rest = token.slice(2); - if (!rest) continue; const eqIndex = rest.indexOf("="); if (eqIndex !== -1) { diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 94bd3cffa168..66777cc04447 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -1,3 +1,8 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { compareSemverVersions } from "./semver.ts"; + const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; export const CLI_RELEASES_PAGE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases`; export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; @@ -68,6 +73,32 @@ export function cliReleaseChannelOf(version: string): CliReleaseChannel { return channel === "nightly" || channel === "preview" ? channel : "stable"; } +/** Whether updating between these versions crosses Stable and Nightly. Preview builds cross nothing. + * Hosts refuse a crossing outside a track switch, which checks the database first. */ +export function crossesReleaseTrack(fromVersion: string, toVersion: string): boolean { + const from = cliReleaseChannelOf(fromVersion); + const to = cliReleaseChannelOf(toVersion); + return from !== to && from !== "preview" && to !== "preview"; +} + +/** The desktop update track a build belongs to. Preview builds have no feed and read as Stable. */ +export function desktopUpdateChannelOf(version: string): "latest" | "nightly" { + return cliReleaseChannelOf(version) === "nightly" ? "nightly" : "latest"; +} + +/** Whether a track switch may install `found` after its database check cleared `checked`: any build + * on the switch's track at least as new, since later releases only add migrations. */ +export function acceptsTrackSwitchRelease( + found: string, + channel: "latest" | "nightly", + checked: string, +): boolean { + return ( + cliReleaseChannelOf(found) === (channel === "nightly" ? "nightly" : "stable") && + compareSemverVersions(found, checked) >= 0 + ); +} + /** Page until the channel matches; nightlies can push preview/stable releases past the first page. */ export function cliReleaseIndexPageUrl(page: number): string { return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`; @@ -89,3 +120,14 @@ export function newestCliReleaseVersion( } return undefined; } + +const NpmReleaseMigrationManifest = Schema.Struct({ + t3code: Schema.Struct({ databaseMigration: Schema.Int }), +}); +const decodeNpmReleaseMigrationManifest = Schema.decodeUnknownOption(NpmReleaseMigrationManifest); + +/** Highest database migration a release's server knows, from its npm `t3` manifest. */ +export function npmReleaseDatabaseMigration(manifest: unknown): number | undefined { + return Option.getOrUndefined(decodeNpmReleaseMigrationManifest(manifest))?.t3code + .databaseMigration; +} diff --git a/packages/shared/src/editor.ts b/packages/shared/src/editor.ts index dd9f11b0751e..57bc67afdcfa 100644 --- a/packages/shared/src/editor.ts +++ b/packages/shared/src/editor.ts @@ -34,7 +34,8 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const platform = yield* HostProcess.Platform; // Prefer Zed's macOS bundle because OpenZFS also installs a zed executable. const preferBundle = platform === "darwin" && editor.id === "zed"; - if (!preferBundle) { + const preferZeditor = platform === "linux" && editor.id === "zed"; + if (!preferBundle && !preferZeditor) { for (const command of editor.commands) { if (yield* isCommandAvailable(command, { env })) return Option.some({ command, baseArgs }); } @@ -129,8 +130,15 @@ export const resolveEditorCommand = Effect.fn("editor.resolveEditorCommand")(fun const dataHome = env.XDG_DATA_HOME || (home ? path.join(home, ".local/share") : undefined); if (dataHome) dirs.push(path.join(dataHome, "JetBrains/Toolbox/scripts")); } - for (const dir of dirs) { - for (const name of editor.commands) candidates.push(path.join(dir, name)); + if (preferZeditor) { + // Linux packages use zeditor because OpenZFS owns the zed command. + for (const name of ["zeditor", "zed"]) { + candidates.push(name, ...dirs.map((dir) => path.join(dir, name))); + } + } else { + for (const dir of dirs) { + for (const name of editor.commands) candidates.push(path.join(dir, name)); + } } } diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 572441ffe536..12af0f39336b 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -232,6 +232,7 @@ export const HTML_RENDER_THEME_GUIDE = [ export const HTML_RENDER_LAYOUT_GUIDE = [ `Frame width is reply width (${HTML_RENDER_COLUMN_WIDTH}px desktop, about 360px phones), on the thread background.`, "Leave html, body, and the outer element with no background color, no outer card, border, banner title, or side padding. This wins over any rule that sets a page background.", + "T3 adds an overridable 8px html gutter for selection rings and shadows. Use fluid widths within it.", "A box that needs its own background gets 16px padding and var(--radius) corners. Give charts fixed pixel heights.", "Let content set the height. Do not use vh units or height:100% on html or body. Both are rejected or loop.", ].join(" "); @@ -353,7 +354,7 @@ export function htmlRenderThemeMessage(theme: HtmlRenderTheme) { } const BASE_CSS = - "html{background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + + "html{box-sizing:border-box;padding:8px;background:var(--background);color:var(--foreground);font-family:var(--font-sans);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;scrollbar-width:none}" + "html::-webkit-scrollbar{display:none}body{margin:0}code,kbd,pre,samp{font-family:var(--font-mono)}" + // Focus outlines paint inside the element, so the frame edge never clips them. ":where(:focus-visible){outline:2px solid var(--ring)}:focus-visible{outline-width:2px!important;outline-offset:-2px!important}" + diff --git a/packages/shared/src/markdownMath.test.ts b/packages/shared/src/markdownMath.test.ts index cb920c073797..0bc6941e494c 100644 --- a/packages/shared/src/markdownMath.test.ts +++ b/packages/shared/src/markdownMath.test.ts @@ -124,8 +124,18 @@ describe("chat math parsing", () => { "It costs $5 and $10.", "Budget $5-$10 per seat, or $20,000-$30,000 a year.", "Plans are $5/$10 monthly.", + "Pay $5,$10 now.", "Run $browser then $deploy on it.", "Set $HOME before running.", + "Set $HOME,$PATH before running.", + "Set $HOME, $PATH before running.", + "Set $HOME,$_PATH before running.", + "Set $home,$path before running.", + "Set $HOME,${PATH} before running.", + "Set $HOME,$? before running.", + "Set $HOME,$@ before running.", + "Set $HOME,$(pwd) before running.", + "Pay $1,000,$2,000 now.", ]) { expect(formulas(prose), prose).toEqual([]); expect(text(prose), prose).toBe(prose); @@ -143,6 +153,17 @@ describe("chat math parsing", () => { } }); + it("reads formulas that end in a comma", () => { + for (const boundary of ["", " ", "\n", ".", ",", ";", ":", ")", "]", "}"]) { + expect(formulas(`$x,$${boundary}`)).toEqual([["inline", "x,"]]); + } + expect(formulas("Where $x = 1,$ with $$a,$$ and $$ b,$$.")).toEqual([ + ["inline", "x = 1,"], + ["display", "a,"], + ["display", " b,"], + ]); + }); + it("leaves code, escapes, links, and citations alone", () => { for (const [markdown, rendered] of [ ["Use `$x$` in code.", "Use in code."], diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 8c27c1859935..aca76c12564c 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,12 +1,18 @@ import { DEFAULT_SERVER_SETTINGS, + OnePasswordAccount, ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, + SecretProviderKind, + ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, type ServerProvider, } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; +import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; @@ -21,9 +27,30 @@ import { resolveSourceControlWriterModelSelection, } from "./serverSettings.ts"; +const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); + const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; describe("fallbackTextGenerationModelSelection", () => { + it("replaces and clears the fallback without carrying model options", () => { + const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" }; + const settings = applyServerSettingsPatch( + { + ...DEFAULT_SERVER_SETTINGS, + fallbackModelSelection: { + ...fallbackModelSelection, + model: "old", + options: [{ id: "effort", value: "high" }], + }, + }, + { fallbackModelSelection }, + ); + expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection); + expect( + applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection, + ).toBeNull(); + }); it("ignores a configured non-default instance at machine scope, matching no fallback", () => { const configured = ProviderInstanceId.make("codex_work"); const providerInstances = { @@ -480,6 +507,37 @@ describe("serverSettings helpers", () => { }); }); + // regression: #18334 a newer client must still see this server drop a key it does not know + it("drops an override key this server does not know from a patch and keeps one it stored", () => { + const projectId = ProjectId.make("project-a"); + const current = decodeServerSettings({ + projectSettingsFolded: true, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, addedByNewerServer: 3 }, + }, + }); + const patch = decodeServerSettingsPatch({ + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, addedByNewerServer: 4, addedByNewerClient: "x" }, + }, + }); + + const updated = applyServerSettingsPatch(current, patch); + expect(updated.projectSettingsOverrides[projectId]).toEqual({ + defaultAutoPull: false, + addedByNewerServer: 3, + }); + const reset = applyServerSettingsPatch(updated, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(reset.projectSettingsOverrides[projectId]).toEqual({ addedByNewerServer: 3 }); + expect( + applyServerSettingsPatch(reset, { + projectSettingsOverrides: { [projectId]: null }, + }).projectSettingsOverrides[projectId], + ).toBeUndefined(); + }); + it("clears a project's providerInstances override when a patch entry sends an empty map", () => { const instanceId = ProviderInstanceId.make("codex_work"); const projectId = ProjectId.make("project-a"); @@ -528,6 +586,28 @@ describe("serverSettings helpers", () => { expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); }); + it("upserts and removes secretProviders per entry so concurrent edits cannot clobber", () => { + const personal = SecretProviderId.make("personal"); + const work = SecretProviderId.make("work"); + const onePassword = (label: string, account: string) => ({ + kind: SecretProviderKind.make("one_password"), + label, + config: { account: OnePasswordAccount.make(account) }, + }); + const current = { + ...DEFAULT_SERVER_SETTINGS, + secretProviders: { [personal]: onePassword("Personal", "my.1password.com") }, + }; + + const added = applyServerSettingsPatch(current, { + secretProviders: { [work]: onePassword("Work", "team.1password.com") }, + }); + expect(Object.keys(added.secretProviders)).toEqual([personal, work]); + + const removed = applyServerSettingsPatch(added, { secretProviders: { [personal]: null } }); + expect(Object.keys(removed.secretProviders)).toEqual([work]); + }); + it("replaces and removes individual usage prices without clobbering other models", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 0c151eb5025a..e936c9f83acb 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -14,7 +14,7 @@ import { type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, - type ProjectSettingsOverrides, + ProjectSettingsOverrides, ProviderDriverKind, ProviderInstanceId, type ServerProvider, @@ -92,13 +92,15 @@ export function fallbackTextGenerationModelSelection( ) { return threadDefault; } - return { - instanceId, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_MODEL_BY_PROVIDER[driver] ?? - DEFAULT_TEXT_GENERATION_MODEL, - }; + return { instanceId, model: defaultTextGenerationModel(driver) }; +} + +export function defaultTextGenerationModel(driver: ProviderDriverKind): string { + return ( + DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_MODEL_BY_PROVIDER[driver] ?? + DEFAULT_TEXT_GENERATION_MODEL + ); } export function resolveSourceControlWriterModelSelection( @@ -224,6 +226,16 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +const knownProjectOverrideKeys = new Set(Object.keys(ProjectSettingsOverrides.fields)); + +function pickOverrideKeys(entry: ProjectSettingsOverrides | undefined, known: boolean) { + return Object.fromEntries( + Object.entries(entry ?? {}).filter( + ([key]) => knownProjectOverrideKeys.has(key) === known, + ), + ); +} + // Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, @@ -237,7 +249,9 @@ function mergeProjectSettingsOverridesPatch( } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { - ...entry, + // Preserve downgraded storage, but reject new unknown writes so clients can detect unsupported settings. + ...pickOverrideKeys(current[projectId], false), + ...pickOverrideKeys(entry, true), ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }), @@ -355,6 +369,7 @@ export function applyServerSettingsPatch( worktreeCleanup: worktreeCleanupPatch, // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, + secretProviders: secretProvidersPatch, sourceControlHosts: sourceControlHostsPatch, usagePriceOverrides: usagePriceOverridesPatch, usageModelAliases: usageModelAliasesPatch, @@ -538,6 +553,9 @@ export function applyServerSettingsPatch( ...(patch.defaultModelSelection !== undefined ? { defaultModelSelection: patch.defaultModelSelection } : {}), + ...(patch.fallbackModelSelection !== undefined + ? { fallbackModelSelection: patch.fallbackModelSelection } + : {}), ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), @@ -549,6 +567,9 @@ export function applyServerSettingsPatch( ), } : {}), + ...(secretProvidersPatch !== undefined + ? { secretProviders: mergeSettingsEntries(current.secretProviders, secretProvidersPatch) } + : {}), ...(usagePriceOverridesPatch !== undefined ? { usagePriceOverrides: mergeSettingsEntries( diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d7..5da7bc2cc02d 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,5 @@ import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProviderDriverKind, ProviderInstanceId, @@ -17,9 +18,11 @@ import { collectLimitNotices, collectLimitPools, elapsedShare, + exhaustedUsageWindow, paceOf, paceRemainingPercent, usesChatGptSharing, + resolveNewThreadModelSelection, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -50,6 +53,106 @@ function provider(overrides: Partial): ServerProvider { }; } +describe("new thread model fallback", () => { + const selection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" }; + const fallback = { instanceId: ProviderInstanceId.make("claude"), model: "claude-fable-5-1" }; + const limited = provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, usedPercent: 100 }], + }, + }); + const available = provider({ + instanceId: fallback.instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + }); + + const defaults = { + defaultSelection: selection, + fallbackSelection: fallback, + providers: [limited, available], + now, + }; + it.each([ + ["exhausted default", {}, fallback], + ["explicit selection", { explicitSelection: selection }, selection], + ["missing default", { defaultSelection: null }, null], + ["no fallback", { fallbackSelection: null }, selection], + ["expired window", { now: Date.parse("2026-09-04T00:00:00Z") }, selection], + [ + "environment disabled", + { + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [fallback.instanceId]: { driver: available.driver, enabled: false, config: {} }, + }, + }, + }, + selection, + ], + ] as const)("resolves %s", (_name, overrides, expected) => { + expect(resolveNewThreadModelSelection({ ...defaults, ...overrides }).selection).toEqual( + expected, + ); + }); + it.each([ + { enabled: false }, + { installed: false }, + { auth: { status: "unauthenticated" as const } }, + { availability: "unavailable" as const }, + { usageLimits: limited.usageLimits }, + ])("rejects an unusable fallback: %j", (overrides) => { + expect( + resolveNewThreadModelSelection({ + ...defaults, + providers: [limited, { ...available, ...overrides }], + }).selection, + ).toEqual(selection); + }); + it.each([undefined, "invalid"])("treats an unknown reset as exhausted: %s", (resetsAt) => { + expect( + exhaustedUsageWindow( + provider({ + usageLimits: { + checkedAt: limited.checkedAt, + windows: [{ ...window, usedPercent: 100, resetsAt }], + }, + }), + now, + ), + ).toBeDefined(); + }); + it.each([ + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], + ["cursor", ["autoPercentUsed"], "composer-2.5", false], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], + ["antigravity", ["gemini-weekly"], "antigravity-default", true], + ["opencode", ["go_weekly"], "opencode/big-pickle", false], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { + const defaultSelection = { ...selection, model }; + expect( + resolveNewThreadModelSelection({ + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); + }); +}); + describe("pace", () => { it("places the clock three fifths through a five-hour window with two hours left", () => { expect(elapsedShare(window, now)).toBeCloseTo(0.6); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5a3e8a415300..add665c2af12 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -1,5 +1,9 @@ import { type EnvironmentId, + type ModelSelection, + type ServerSettings, + type ProjectId, + resolveProjectProviderInstanceEnabled, type UsageLimitsReport, type ProviderInstanceId, type ProviderConsumeResetCreditInput, @@ -21,6 +25,85 @@ const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; +export function exhaustedUsageWindow( + provider: ServerProvider | undefined, + now: number, + model?: string, +) { + const exhausted = (provider?.usageLimits?.windows ?? []).filter( + (window) => + (window.usedPercent ?? 0) >= 100 && + (!window.resetsAt || + !Number.isFinite(Date.parse(window.resetsAt)) || + Date.parse(window.resetsAt) > now), + ); + if (model === undefined) return exhausted[0]; + switch (provider?.driver) { + case "cursor": { + const api = exhausted.find((window) => window.id === "apiPercentUsed"); + const auto = exhausted.find((window) => window.id === "autoPercentUsed"); + return /^(auto|default|grok|composer)/.test(model) ? auto && api : api; + } + case "antigravity": { + const family = model.match(/^(gemini|claude|gpt)-/)?.[1]; + return exhausted.find((window) => { + const pool = window.id.match(/^(gemini|claude|gpt|3p)-/)?.[1]; + return ( + pool === undefined || + family === undefined || + pool === family || + (pool === "3p" && (family === "claude" || family === "gpt")) + ); + }); + } + case "opencode": + return exhausted.find( + (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), + ); + default: + return exhausted[0]; + } +} + +export function resolveNewThreadModelSelection(input: { + readonly defaultSelection: ModelSelection | null; + readonly explicitSelection?: ModelSelection | null; + readonly fallbackSelection: ModelSelection | null; + readonly providers: readonly ServerProvider[]; + readonly now: number; + readonly settings?: ServerSettings; + readonly projectId?: ProjectId | null; +}) { + const selection = input.explicitSelection ?? input.defaultSelection; + const unchanged = { selection, fallback: null }; + if (input.explicitSelection || !selection || !input.fallbackSelection) return unchanged; + const provider = input.providers.find((entry) => entry.instanceId === selection.instanceId); + const window = exhaustedUsageWindow(provider, input.now, selection.model); + const fallback = input.providers.find( + (entry) => entry.instanceId === input.fallbackSelection?.instanceId, + ); + if ( + !provider || + !window || + !fallback?.enabled || + !fallback.installed || + fallback.auth.status === "unauthenticated" || + !isProviderAvailable(fallback) || + exhaustedUsageWindow(fallback, input.now, input.fallbackSelection.model) || + (input.settings && + !resolveProjectProviderInstanceEnabled( + input.settings, + input.projectId ?? null, + fallback.instanceId, + )) + ) + return unchanged; + return { + selection: input.fallbackSelection, + fallback: { provider, resetsAt: window.resetsAt ?? null }, + }; +} + export const CHATGPT_USAGE_URL = "https://chatgpt.com/#settings/Usage"; export const CHATGPT_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"; /** A ChatGPT Business admin caps plan use in other apps; only the admin can raise it. */ diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index 26891c354897..c0464276abf2 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -513,7 +513,7 @@ it.effect("never puts a saved token that is unsafe for an HTTP header on the wir }); it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { - const { layer } = makeLayer({ + const { execute, layer } = makeLayer({ response: () => new Response(null, { status: 401 }), env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, }); @@ -523,6 +523,7 @@ it.effect("reports saved credentials as configured when Bitbucket cannot confirm const settings = yield* TestSourceControlHost.TestSourceControlHostSettings; assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + assert.strictEqual(execute.mock.calls.length, 0); yield* settings.update({ sourceControlHosts: { bitbucket: { accessToken: "saved-access-token" } }, diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 72077040aa87..863b206d9557 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -986,18 +986,25 @@ export const make = Effect.gen(function* () { return BitbucketApi.of({ request, - probeAuth: executeJson( - "probeAuth", - HttpClientRequest.get(apiUrl("/user")), - BitbucketUserSchema, - ).pipe( - Effect.map((user) => ({ - status: "authenticated" as const, - account: nonEmpty(user.username ?? user.display_name ?? user.account_id), - host: Option.some("bitbucket.org"), - detail: Option.none(), - })), - Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + // Bitbucket answers anonymous /user with 401, so without a credential there is nothing to ask. + probeAuth: currentCredential.pipe( + Effect.flatMap((credential) => + credential === null + ? Effect.succeed(authFromCredential(null)) + : executeJson( + "probeAuth", + HttpClientRequest.get(apiUrl("/user")), + BitbucketUserSchema, + ).pipe( + Effect.map((user) => ({ + status: "authenticated" as const, + account: nonEmpty(user.username ?? user.display_name ?? user.account_id), + host: Option.some("bitbucket.org"), + detail: Option.none(), + })), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), + ), + ), ), listPullRequests: Effect.fn("BitbucketApi.listPullRequests")(function (input) { return resolveRepository(input).pipe( diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index b300f14afb0c..46fc8fd48b0d 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -182,6 +182,7 @@ export interface ProviderChangeRequestStat { } export interface ProviderChangeRequestDetail extends ProviderChangeRequest { + readonly maintainerCanModify?: boolean; readonly headSha?: string | null; readonly body: string; readonly changedFiles: number; @@ -462,6 +463,7 @@ export interface PullRequestProviderApi { /** Requires edit capability and at least one field. */ readonly updateChangeRequest?: ( input: ProviderRepositoryRef & { + readonly maintainerCanModify?: boolean | undefined; readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index 66889ddc4f2a..2f018bfa10ca 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -75,6 +75,8 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { }; const DEFAULT_PROBE_TIMEOUT_MS = 5_000; +/** An API host that stalls must not hold the whole discovery answer behind it. */ +const API_PROBE_TIMEOUT = "3 seconds"; function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; @@ -236,6 +238,12 @@ export function probeSourceControlProvider(input: { if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); if (input.spec.type === "api") { return input.spec.probeAuth.pipe( + Effect.timeoutOption(API_PROBE_TIMEOUT), + Effect.map( + Option.getOrElse(() => + unknownAuth("The hosting service did not answer in time. Refresh to try again."), + ), + ), Effect.map( (auth) => ({ @@ -253,20 +261,15 @@ export function probeSourceControlProvider(input: { const spec = input.spec; - return probeCli({ - spec, - process: input.process, - cwd: input.cwd, - }).pipe( - Effect.flatMap((item) => { - if (item.status !== "available") { - return Effect.succeed({ - ...item, - auth: unknownAuth("Hosting integration command was not found on the server PATH."), - } satisfies SourceControlProviderDiscoveryItem); - } - - return input.process + // The auth check does not depend on the version output, so both spawn at once. + return Effect.all( + [ + probeCli({ + spec, + process: input.process, + cwd: input.cwd, + }), + input.process .run({ operation: "source-control.discovery.auth", command: spec.executable, @@ -278,21 +281,24 @@ export function probeSourceControlProvider(input: { appendTruncationMarker: true, }) .pipe( - Effect.map( - (result) => - ({ - ...item, - auth: spec.parseAuth(result), - }) satisfies SourceControlProviderDiscoveryItem, - ), + Effect.map(spec.parseAuth), Effect.catch((cause) => - Effect.succeed({ - ...item, - auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), - } satisfies SourceControlProviderDiscoveryItem), + Effect.succeed(unknownAuth(Option.getOrUndefined(detailFromCause(cause)))), ), - ); - }), + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map( + ([item, auth]) => + ({ + ...item, + auth: + item.status === "available" + ? auth + : unknownAuth("Hosting integration command was not found on the server PATH."), + }) satisfies SourceControlProviderDiscoveryItem, + ), ); } diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 3fd74e08130e..a4b5eb8da4c2 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -68,6 +68,7 @@ const coreResponse = (pullRequest: Readonly> = {}) => ({ mergedAt: null, closedAt: null, isCrossRepository: false, + maintainerCanModify: true, headRepositoryOwner: null, headRepository: null, headRef: null, @@ -710,6 +711,25 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { + it.effect("turns maintainer edits off without rewriting the pull request's text", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((call) => + Effect.succeed( + output( + encodeJson( + call.kind === "graphql" && matches(call, NODE_ID_QUERY) ? nodeIdAnswer("PR_7") : {}, + ), + ), + ), + ); + const api = yield* GitHubPullRequestApi.GitHubPullRequestApi; + yield* api.updatePullRequest({ ...pullRequestInput, maintainerCanModify: false }); + expect(variablesOf("mutation UpdatePullRequest")).toEqual([ + { pullRequestId: "PR_7", maintainerCanModify: false }, + ]); + }), + ); + it.effect.each(["mixed", "merges", "unavailable"] as const)( "reads stats only for displayed non-merge commits (%s)", (kind) => diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 4b7014a89074..16a7a1df18cd 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -705,6 +705,7 @@ export class GitHubPullRequestApi extends Context.Service< }) => Effect.Effect; readonly updatePullRequest: (input: { + readonly maintainerCanModify?: boolean | undefined; readonly cwd: string; readonly repository: string; readonly host: string; @@ -2740,6 +2741,9 @@ export const make = Effect.gen(function* () { client .UpdatePullRequest({ pullRequestId, + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }), }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 81623592066e..dc8ca69770e4 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -16,6 +16,8 @@ import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullReque import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; const coreFields = { + maintainerCanModify: true, + canEditMaintainerEdits: false, reviewRequestTeams: [], checksTruncated: false, comparison: { behindBy: 0, viewerCanUpdate: true }, @@ -205,6 +207,7 @@ describe("gitHubViewerPermissions", () => { }); expect(detail.viewerPermissions).toEqual({ + maintainerEdits: false, actions: ["ready", "draft", "close", "reopen"], comment: true, resolve: false, diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index f90941bf882e..4f3490eec97b 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -49,7 +49,7 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, - edit: { changeRequest: true, comment: true }, + edit: { changeRequest: true, comment: true, maintainerEdits: true }, stacks: true, stackActions: true, labels: true, @@ -453,10 +453,13 @@ export const make = Effect.gen(function* () { ...pullRequest.reviewRequestTeams, ], mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions({ - ...pullRequest.viewerAccess, - canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, - }), + viewerPermissions: { + ...gitHubViewerPermissions({ + ...pullRequest.viewerAccess, + canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true, + }), + maintainerEdits: pullRequest.canEditMaintainerEdits, + }, baseComparison: pullRequest.comparison === null || pullRequest.comparison.behindBy === null ? "unknown" @@ -666,6 +669,9 @@ export const make = Effect.gen(function* () { updateChangeRequest: (input) => cli .updatePullRequest({ + ...(input.maintainerCanModify === undefined + ? {} + : { maintainerCanModify: input.maintainerCanModify }), cwd: input.cwd, repository: input.repository, host: input.host, diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts index 58b094063ecb..29bf922020d0 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.test.ts @@ -227,3 +227,41 @@ it.effect("bounds template reads and marks truncated content", () => }), ), ); + +it.effect.each([ + ".github/pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.TXT", + "docs/PuLl_ReQuEsT_TeMpLaTe.Md", + ".github/PULL_REQUEST_TEMPLATE/template.TxT", +])("reads committed text and mixed-case template $0 from a subdirectory", (relativePath) => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* runGit(cwd, ["config", "grep.patternType", "fixed"]); + yield* runGit(cwd, ["config", "color.grep", "always"]); + yield* writeTemplate(cwd, relativePath, "committed template"); + yield* writeTemplate(cwd, "nested/README.md", "project"); + for (let index = 0; index < 600; index += 1) { + yield* writeTemplate(cwd, `docs/${index}-${"a".repeat(180)}.md`, "guide"); + } + yield* commitTemplates(cwd); + yield* writeTemplate(cwd, relativePath, "uncommitted template"); + + assert.strictEqual( + Option.getOrUndefined(yield* detectTemplate(path.join(cwd, "nested"))), + "committed template", + ); + }), + ), +); + +it.effect("does not guess between Markdown and text directory templates", () => + runWithTempDirectory((cwd) => + Effect.gen(function* () { + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/a.md", "markdown"); + yield* writeTemplate(cwd, ".github/PULL_REQUEST_TEMPLATE/b.TXT", "text"); + yield* commitTemplates(cwd); + assert.isTrue(Option.isNone(yield* detectTemplate(cwd))); + }), + ), +); diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index c6af80389d1f..9edbccdd504d 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -11,10 +11,16 @@ const TRUNCATION_MARKER = "[truncated]"; const TEMPLATE_PATHS = [ ".github/pull_request_template.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.txt", + ".github/PULL_REQUEST_TEMPLATE.txt", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.txt", + "PULL_REQUEST_TEMPLATE.txt", "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", + "docs/pull_request_template.txt", + "docs/PULL_REQUEST_TEMPLATE.txt", ] as const; const TEMPLATE_DIRECTORIES = [ @@ -23,8 +29,6 @@ const TEMPLATE_DIRECTORIES = [ "docs/PULL_REQUEST_TEMPLATE", ] as const; -const TREE_PATHS = [...TEMPLATE_PATHS, ...TEMPLATE_DIRECTORIES] as const; - type ExecuteGit = SourceControlHost.SourceControlHost["Service"]["git"]["execute"]; interface TemplateTreeEntry { @@ -105,7 +109,7 @@ const readTemplateDirectory = Effect.fn("gitHubChangeRequestTemplate.readTemplat return false; } const relativePath = entry.path.slice(prefix.length); - return !relativePath.includes("/") && relativePath.toLowerCase().endsWith(".md"); + return !relativePath.includes("/") && /\.(?:md|txt)$/i.test(relativePath); }); const templates: string[] = []; @@ -132,10 +136,50 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* ) { return yield* Effect.gen(function* () { // Read committed regular blobs; worktree paths can become symlinks between validation and open. + // ls-tree does not support case-insensitive pathspecs; grep discovers only matching names. + const paths = yield* executeGit({ + operation: "gitHubChangeRequestTemplate.findTemplatePaths", + cwd, + args: [ + "grep", + "-G", + "--no-color", + "-l", + "-z", + "--full-name", + "--no-recurse-submodules", + "-e", + "^", + treeish, + "--", + ...TEMPLATE_PATHS.map((path) => `:(top,icase,literal)${path}`), + ], + maxOutputBytes: TREE_LIST_MAX_BYTES, + }).pipe( + Effect.map((result) => + result.stdoutTruncated + ? [] + : result.stdout + .split("\0") + .filter((path) => path.startsWith(`${treeish}:`)) + .map((path) => path.slice(treeish.length + 1)), + ), + // grep exits with status 1 when no nonempty templates match. + Effect.orElseSucceed(() => []), + ); const result = yield* executeGit({ operation: "gitHubChangeRequestTemplate.listTemplates", cwd, - args: ["ls-tree", "-r", "-z", "--full-tree", treeish, "--", ...TREE_PATHS], + args: [ + "ls-tree", + "-r", + "-z", + "--full-tree", + treeish, + "--", + ...new Set([...TEMPLATE_PATHS, ...paths]), + ...TEMPLATE_DIRECTORIES, + ], maxOutputBytes: TREE_LIST_MAX_BYTES, appendTruncationMarker: true, }); @@ -144,12 +188,20 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* } const entries = parseTemplateTreeEntries(result.stdout); - const entriesByPath = new Map(entries.map((entry) => [entry.path, entry])); - for (const templatePath of TEMPLATE_PATHS) { - const entry = entriesByPath.get(templatePath); - if (!entry) { - continue; - } + const candidates = [".github/", "", "docs/"].flatMap((location) => { + const paths = TEMPLATE_PATHS.filter( + (path) => path.slice(0, path.lastIndexOf("/") + 1) === location, + ); + return [ + ...paths.flatMap((path) => entries.filter((entry) => entry.path === path)), + ...entries.filter( + (entry) => + !paths.some((path) => path === entry.path) && + paths.some((path) => path.toLowerCase() === entry.path.toLowerCase()), + ), + ]; + }); + for (const entry of candidates) { const template = yield* readTemplateBlob({ cwd, executeGit, entry }); if (Option.isSome(template)) { return template; diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 4e6321aa3c7a..622b48db3b85 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -362,6 +362,7 @@ describe("pull request detail decoding", () => { baseRefName: "main", headRefOid: "abc123", isCrossRepository: false, + maintainerCanModify: true, headRepositoryOwner: null, headRepository: null, headRef: null, @@ -394,6 +395,30 @@ describe("pull request detail decoding", () => { pullRequest?: Record, ) => expectSuccess(decodePullRequestCoreJson(coreJson(rollup, pullRequest))); + it.each([ + [{}, true], + [{ viewerDidAuthor: false }, false], + [{ isCrossRepository: false }, false], + [{ state: "CLOSED" }, false], + [{ headRepositoryOwner: { __typename: "Organization", login: "team" } }, false], + [{ headRepository: null }, false], + ])( + "reads maintainer edits and lets only the author of an open personal fork change them: %j", + (overrides, allowed) => { + const detail = detailOf([], { + maintainerCanModify: false, + isCrossRepository: true, + viewerDidAuthor: true, + headRepositoryOwner: { __typename: "User", login: "octocat" }, + headRepository: { nameWithOwner: "octocat/t3code", viewerPermission: "ADMIN" }, + headRef: { id: "REF_7" }, + ...overrides, + }); + expect(detail.maintainerCanModify).toBe(false); + expect(detail.canEditMaintainerEdits).toBe(allowed); + }, + ); + const detailJson = JSON.stringify({ comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], reviews: [ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 965855a4350a..e7c05a4aa844 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -978,6 +978,8 @@ export function toPullRequestWatchFingerprint( } export interface GitHubPullRequestCore extends GitHubPullRequestDetail { + readonly maintainerCanModify: boolean; + readonly canEditMaintainerEdits: boolean; readonly reviewRequestTeams: ReadonlyArray; readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; @@ -1023,6 +1025,14 @@ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullReq statusCheckRollup: toCheckContexts(nodesOf(contexts)), }), headRepositoryNameWithOwner: trimmed(pr.headRepository?.nameWithOwner), + maintainerCanModify: pr.maintainerCanModify, + canEditMaintainerEdits: + pr.state === "OPEN" && + pr.isCrossRepository && + pr.viewerDidAuthor && + pr.headRepositoryOwner?.__typename === "User" && + pr.headRepository !== null && + pr.headRef !== null, viewerAccess: { canWrite: toCanWrite(repository.viewerPermission), canDeleteBranch: toCanWrite(pr.headRepository?.viewerPermission) && pr.headRef !== null, diff --git a/packages/source-control-github/src/server/pullRequestMutations.graphql b/packages/source-control-github/src/server/pullRequestMutations.graphql index 4ba7854507af..3e2d5b3133ad 100644 --- a/packages/source-control-github/src/server/pullRequestMutations.graphql +++ b/packages/source-control-github/src/server/pullRequestMutations.graphql @@ -41,11 +41,23 @@ mutation UnresolveReviewThread($threadId: ID!) { } } -# Rewrites the pull request's own words. Both are nullable so that one document serves a change to -# the title, to the description, or to the two together: a variable the request does not send puts -# no entry in the input at all, which leaves that field as it was rather than clearing it. -mutation UpdatePullRequest($pullRequestId: ID!, $title: String, $body: String) { - updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { +# Rewrites the pull request's own words or its maintainer-edit setting. Every field is nullable so +# that one document serves any combination: a variable the request does not send puts no entry in +# the input at all, which leaves that field as it was rather than clearing it. +mutation UpdatePullRequest( + $pullRequestId: ID! + $title: String + $body: String + $maintainerCanModify: Boolean +) { + updatePullRequest( + input: { + pullRequestId: $pullRequestId + title: $title + body: $body + maintainerCanModify: $maintainerCanModify + } + ) { pullRequest { id } diff --git a/packages/source-control-github/src/server/pullRequestMutations.graphql.ts b/packages/source-control-github/src/server/pullRequestMutations.graphql.ts index 05a67b81d4e5..d11b2b1e69fc 100644 --- a/packages/source-control-github/src/server/pullRequestMutations.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestMutations.graphql.ts @@ -205,17 +205,19 @@ export const UpdatePullRequest = GraphQL.mutation("UpdatePullRequest", { variables: [ { name: "pullRequestId", body: "ID!" }, { name: "title", body: "String" }, - { name: "body", body: "String" } + { name: "body", body: "String" }, + { name: "maintainerCanModify", body: "Boolean" } ], selections: [ - { name: "updatePullRequest", body: ["(input:{pullRequestId:$", "pullRequestId", " title:$", "title", " body:$", "body", "}){pullRequest{id}}"] } + { name: "updatePullRequest", body: ["(input:{pullRequestId:$", "pullRequestId", " title:$", "title", " body:$", "body", " maintainerCanModify:$", "maintainerCanModify", "}){pullRequest{id}}"] } ], fragments: [] }, variables: { pullRequestId: Shared.ID, title: Schema.optional(Schema.NullOr(Schema.String)), - body: Schema.optional(Schema.NullOr(Schema.String)) + body: Schema.optional(Schema.NullOr(Schema.String)), + maintainerCanModify: Schema.optional(Schema.NullOr(Schema.Boolean)) }, result: Schema.Struct({ /** Update a pull request */ diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index d5a95256fc1a..78e5efe0e455 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -87,6 +87,7 @@ query PullRequestCore( baseRefName headRefOid isCrossRepository + maintainerCanModify headRepositoryOwner { login } diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index e261ec0240a7..5bc09fa4e2ce 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -395,6 +395,8 @@ export const PullRequestCoreRepositoryPullRequestPullRequest = Schema.Struct({ headRefOid: Shared.GitObjectID, /** The head and base repositories are different. */ isCrossRepository: Schema.Boolean, + /** Indicates whether maintainers can modify the pull request. */ + maintainerCanModify: Schema.Boolean, /** The owner of the repository associated with this pull request's head Ref. */ headRepositoryOwner: Schema.NullOr(RepositoryOwner), /** The repository associated with this pull request's head Ref. */ @@ -457,7 +459,7 @@ export const PullRequestCore = GraphQL.query("PullRequestCore", { { name: "requestedReviewers", body: "Int!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed autoMergeAllowed viewerPermission pullRequest(number:$", "number", "){number title url body state isDraft mergeable mergeStateStatus reviewDecision additions deletions changedFiles createdAt updatedAt mergedAt closedAt headRefName baseRefName headRefOid isCrossRepository maintainerCanModify headRepositoryOwner{__typename login}headRepository{nameWithOwner viewerPermission}headRef{id}author{__typename login avatarUrl...on User{id name}}autoMergeRequest{mergeMethod}viewerCanUpdate viewerDidAuthor viewerCanUpdateBranch baseRef{compare(headRef:$", "headRef", "){behindBy}}reviewRequests(first:$", "requestedReviewers", "){nodes{requestedReviewer{__typename...on User{login name}...on Bot{login}...on Team{slug combinedSlug teamName:name teamAvatarUrl:avatarUrl}}}}labels(first:100){nodes{name color}}commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename...", "CheckContext", "}pageInfo{hasNextPage}}}}}}}}"] } ], fragments: [ { name: "CheckContext", body: ["on StatusCheckRollupContext{__typename...on StatusContext{context state targetUrl createdAt description isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")}...on CheckRun{name status conclusion startedAt completedAt detailsUrl isRequired(pullRequestNumber:$", "number", ")@include(if:$", "required", ")checkSuite{workflowRun{workflow{name}}}}}"] } diff --git a/packages/source-control-gitlab/src/shared/definition.test.ts b/packages/source-control-gitlab/src/shared/definition.test.ts index a05ee047384d..7f9e94fa9088 100644 --- a/packages/source-control-gitlab/src/shared/definition.test.ts +++ b/packages/source-control-gitlab/src/shared/definition.test.ts @@ -31,6 +31,28 @@ describe("GitLab change request URLs", () => { expect(parse("https://gitlab.com/t3code/-/merge_requests/12")).toBeNull(); }); + it("recognizes only merge request URLs it can parse", () => { + for (const url of [ + "https://gitlab.com/g/p?x=/-/merge_requests/5", + "https://gitlab.com/g/p#/-/merge_requests/5", + "https://gitlab.com?x=/g/p/-/merge_requests/5", + "https://gitlab.com#/g/p/-/merge_requests/5", + "https://gitlab.com/t3code/-/merge_requests/12", + "https://gitlab.com/g//-/merge_requests/5", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(false); + expect(parse(url), url).toBeNull(); + } + for (const url of [ + "https://code.acme.test/a/b/c/-/merge_requests/7?tab=diffs", + "http://git.internal/a/b/c/-/merge_requests/7#note_1", + "https://code.acme.test/a/b/c/-/merge_requests/7/diffs", + ]) { + expect(definition.isChangeRequestReference(url), url).toBe(true); + expect(parse(url), url).toMatchObject({ repository: "a/b/c", number: 7 }); + } + }); + it("builds merge request URLs below the repository", () => { expect( definition.changeRequestUrl({ diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 1a3c417641ce..437320c0d041 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -11,7 +11,7 @@ import { icon } from "./icon.ts"; const CHECKOUT_COMMAND = /^glab\s+mr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = - /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; + /^https?:\/\/[^/?#\s]+\/[^/?#\s]+(?:\/[^/?#\s]+)+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 6871ebcfd18c..82a6319aefd6 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -56,6 +56,39 @@ describe("ssh config", () => { }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); + it.effect("skips directory Include matches without losing file aliases or known hosts", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* makeTempHomeDir(); + const sshDir = path.join(homeDir, ".ssh"); + yield* fs.makeDirectory(path.join(sshDir, "config.d", "ignored"), { recursive: true }); + yield* fs.makeDirectory(path.join(sshDir, "explicit-directory")); + yield* fs.writeFileString( + path.join(sshDir, "config"), + "Include config.d/*\nInclude explicit-directory\nHost real\n HostName real.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "config.d", "team.conf"), + "Host team\n HostName team.example.com\n", + ); + yield* fs.writeFileString( + path.join(sshDir, "known_hosts"), + "known.example.com ssh-ed25519 AAAA\n", + ); + + const hosts = yield* discoverSshHosts({ homeDir }); + assert.deepEqual( + hosts.map((host) => [host.alias, host.hostname, host.source]), + [ + ["known.example.com", "known.example.com", "known-hosts"], + ["real", "real.example.com", "ssh-config"], + ["team", "team.example.com", "ssh-config"], + ], + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + it.effect("discovers ssh config hosts across included files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 634d38aa0f7f..8b6683a968a3 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -177,6 +177,7 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( return; } if (!(yield* fs.exists(resolvedPath))) return; + if ((yield* fs.stat(resolvedPath)).type === "Directory") return; visited.add(resolvedPath); const directory = path.dirname(resolvedPath); diff --git a/patches/@expo__ui@58.0.20.patch b/patches/@expo__ui@58.0.20.patch index 2546d83b8042..859ee062b9fd 100644 --- a/patches/@expo__ui@58.0.20.patch +++ b/patches/@expo__ui@58.0.20.patch @@ -1,8 +1,49 @@ diff --git a/android/src/main/java/expo/modules/ui/ExpoUIModule.kt b/android/src/main/java/expo/modules/ui/ExpoUIModule.kt -index d58b54f..1920b1a 100644 +index d58b54fc30ba0f9e5bc35b505ae598161a59b9b1..2da4df803a918d034921d41d49d92121e6303f82 100644 --- a/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +++ b/android/src/main/java/expo/modules/ui/ExpoUIModule.kt -@@ -533,6 +533,10 @@ class ExpoUIModule : Module() { +@@ -29,6 +29,13 @@ import expo.modules.ui.button.IconButtonContent + import expo.modules.ui.button.FilledIconButtonContent + import expo.modules.ui.button.FilledTonalIconButtonContent + import expo.modules.ui.button.OutlinedIconButtonContent ++import expo.modules.ui.button.SplitButtonCheckedChangeEvent ++import expo.modules.ui.button.SplitButtonLayoutContent ++import expo.modules.ui.button.SplitButtonLayoutProps ++import expo.modules.ui.button.SplitButtonLeadingButtonContent ++import expo.modules.ui.button.SplitButtonLeadingButtonProps ++import expo.modules.ui.button.SplitButtonTrailingButtonContent ++import expo.modules.ui.button.SplitButtonTrailingButtonProps + import expo.modules.ui.graphics.ImageLoader + import expo.modules.ui.icon.IconView + import expo.modules.ui.image.ImageView +@@ -294,6 +301,26 @@ class ExpoUIModule : Module() { + } + } + ++ ExpoUIView("SplitButtonLayoutView") { ++ Content { props -> SplitButtonLayoutContent(props) } ++ } ++ ++ ExpoUIView("SplitButtonLeadingButtonView") { ++ val onButtonPressed by Event() ++ ++ Content { props -> ++ SplitButtonLeadingButtonContent(props) { onButtonPressed(Unit) } ++ } ++ } ++ ++ ExpoUIView("SplitButtonTrailingButtonView") { ++ val onCheckedChange by Event() ++ ++ Content { props -> ++ SplitButtonTrailingButtonContent(props) { onCheckedChange(it) } ++ } ++ } ++ + ExpoUIView("FilledTonalButton") { + val onButtonPressed by Event() + +@@ -533,6 +560,10 @@ class ExpoUIModule : Module() { } } @@ -14,7 +55,7 @@ index d58b54f..1920b1a 100644 Content { props -> HorizontalFloatingToolbarContent(props) diff --git a/android/src/main/java/expo/modules/ui/HostView.kt b/android/src/main/java/expo/modules/ui/HostView.kt -index 4887034..6eaf039 100644 +index 4887034bf7e3e6b5d8d2c1ae25932f5a24f9435d..6eaf039a14820ddaf635a1538c2a9a644cf704fb 100644 --- a/android/src/main/java/expo/modules/ui/HostView.kt +++ b/android/src/main/java/expo/modules/ui/HostView.kt @@ -69,6 +69,7 @@ internal enum class ExpoLayoutDirection(val value: String) : Enumerable { @@ -89,7 +130,7 @@ index 4887034..6eaf039 100644 // Material3's `MaterialTheme` does not provide `LocalContentColor` โ€” only `Surface` does โ€” so diff --git a/android/src/main/java/expo/modules/ui/TopAppBarView.kt b/android/src/main/java/expo/modules/ui/TopAppBarView.kt new file mode 100644 -index 0000000..4ed0132 +index 0000000000000000000000000000000000000000..4ed0132d57fc7aff08ecb7ee84fb8fa48942931b --- /dev/null +++ b/android/src/main/java/expo/modules/ui/TopAppBarView.kt @@ -0,0 +1,47 @@ @@ -140,8 +181,109 @@ index 0000000..4ed0132 + modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) + ) +} +diff --git a/android/src/main/java/expo/modules/ui/button/SplitButton.kt b/android/src/main/java/expo/modules/ui/button/SplitButton.kt +new file mode 100644 +index 0000000000000000000000000000000000000000..10df003066fb0703645c4306bd557590b2cae9aa +--- /dev/null ++++ b/android/src/main/java/expo/modules/ui/button/SplitButton.kt +@@ -0,0 +1,95 @@ ++@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) ++ ++package expo.modules.ui.button ++ ++import androidx.compose.material3.ButtonDefaults ++import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi ++import androidx.compose.material3.SplitButtonDefaults ++import androidx.compose.material3.SplitButtonLayout ++import androidx.compose.runtime.Composable ++import expo.modules.kotlin.records.Field ++import expo.modules.kotlin.records.Record ++import expo.modules.kotlin.types.OptimizedRecord ++import expo.modules.kotlin.views.ComposeProps ++import expo.modules.kotlin.views.FunctionalComposableScope ++import expo.modules.kotlin.views.OptimizedComposeProps ++import expo.modules.ui.ModifierList ++import expo.modules.ui.ModifierRegistry ++import expo.modules.ui.UIComposableScope ++import expo.modules.ui.compose ++import expo.modules.ui.isSlotWithName ++ ++@OptimizedComposeProps ++data class SplitButtonLayoutProps( ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedComposeProps ++data class SplitButtonLeadingButtonProps( ++ val colors: ButtonColors = ButtonColors(), ++ val enabled: Boolean = true, ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedComposeProps ++data class SplitButtonTrailingButtonProps( ++ val checked: Boolean = false, ++ val colors: ButtonColors = ButtonColors(), ++ val enabled: Boolean = true, ++ val modifiers: ModifierList = emptyList() ++) : ComposeProps ++ ++@OptimizedRecord ++data class SplitButtonCheckedChangeEvent( ++ @Field val checked: Boolean = false ++) : Record ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonLayoutContent(props: SplitButtonLayoutProps) { ++ SplitButtonLayout( ++ leadingButton = { Children(UIComposableScope(), filter = { isSlotWithName(it, "leadingButton") }) }, ++ trailingButton = { Children(UIComposableScope(), filter = { isSlotWithName(it, "trailingButton") }) }, ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) ++} ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonLeadingButtonContent( ++ props: SplitButtonLeadingButtonProps, ++ onClick: () -> Unit ++) { ++ SplitButtonDefaults.LeadingButton( ++ onClick = onClick, ++ enabled = props.enabled, ++ colors = ButtonDefaults.buttonColors( ++ containerColor = props.colors.containerColor.compose, ++ contentColor = props.colors.contentColor.compose, ++ disabledContainerColor = props.colors.disabledContainerColor.compose, ++ disabledContentColor = props.colors.disabledContentColor.compose ++ ), ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) { ++ Children(UIComposableScope(rowScope = this)) ++ } ++} ++ ++@Composable ++fun FunctionalComposableScope.SplitButtonTrailingButtonContent( ++ props: SplitButtonTrailingButtonProps, ++ onCheckedChange: (SplitButtonCheckedChangeEvent) -> Unit ++) { ++ SplitButtonDefaults.TrailingButton( ++ checked = props.checked, ++ onCheckedChange = { onCheckedChange(SplitButtonCheckedChangeEvent(it)) }, ++ enabled = props.enabled, ++ colors = ButtonDefaults.buttonColors( ++ containerColor = props.colors.containerColor.compose, ++ contentColor = props.colors.contentColor.compose, ++ disabledContainerColor = props.colors.disabledContainerColor.compose, ++ disabledContentColor = props.colors.disabledContentColor.compose ++ ), ++ modifier = ModifierRegistry.applyModifiers(props.modifiers, appContext, composableScope, globalEventDispatcher) ++ ) { ++ Children(UIComposableScope(rowScope = this)) ++ } ++} diff --git a/build/jetpack-compose/Host/index.d.ts b/build/jetpack-compose/Host/index.d.ts -index b586b65..e32f6e0 100644 +index b586b653a991ce8840b9994a6fe1e6af75480ece..e32f6e093fde22e9cfc9d7ff612cddb59e036945 100644 --- a/build/jetpack-compose/Host/index.d.ts +++ b/build/jetpack-compose/Host/index.d.ts @@ -1,6 +1,7 @@ @@ -161,7 +303,7 @@ index b586b65..e32f6e0 100644 * The layout direction for the content. * Defaults to the current locale direction from I18nManager. diff --git a/build/jetpack-compose/Host/index.js b/build/jetpack-compose/Host/index.js -index 3985656..2b4a4f9 100644 +index 39856563a8cf1119f3a72e37fd35d48d245041bf..2b4a4f946f2ff04a5563c4cd8a29f28b0c5cc08b 100644 --- a/build/jetpack-compose/Host/index.js +++ b/build/jetpack-compose/Host/index.js @@ -8,16 +8,17 @@ import { useMergeRefs } from '../../utils/useMergeRefs'; @@ -194,9 +336,141 @@ index 3985656..2b4a4f9 100644 onLayoutContent: onLayoutContent, layoutDirection: layoutDirection ?? (I18nManager.getConstants().isRTL ? 'rightToLeft' : 'leftToRight'), ref: mergedRef, +diff --git a/build/jetpack-compose/SplitButton/index.d.ts b/build/jetpack-compose/SplitButton/index.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..4c228bd16b10c8d08d96c96bb143e07769d06aa6 +--- /dev/null ++++ b/build/jetpack-compose/SplitButton/index.d.ts +@@ -0,0 +1,73 @@ ++import type { ReactNode } from 'react'; ++import type { ModifierConfig } from '../../types'; ++import type { ButtonColors } from '../Button'; ++export interface SplitButtonLayoutProps { ++ /** ++ * A `SplitButtonLayout.LeadingButton` and a `SplitButtonLayout.TrailingButton`. ++ */ ++ children: ReactNode; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++} ++export interface SplitButtonLeadingButtonProps { ++ /** ++ * Callback that is called when the leading button is clicked. ++ */ ++ onClick?: () => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button. ++ */ ++ children: ReactNode; ++} ++export interface SplitButtonTrailingButtonProps { ++ /** ++ * Whether the trailing button is checked, usually while its menu is expanded. ++ */ ++ checked: boolean; ++ /** ++ * Callback that is called when the checked state changes. ++ */ ++ onCheckedChange?: (checked: boolean) => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button, usually a chevron icon. ++ */ ++ children: ReactNode; ++} ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ ++declare function SplitButtonLayout(props: SplitButtonLayoutProps): import("react/jsx-runtime").JSX.Element; ++declare namespace SplitButtonLayout { ++ var LeadingButton: (props: SplitButtonLeadingButtonProps) => import("react/jsx-runtime").JSX.Element; ++ var TrailingButton: (props: SplitButtonTrailingButtonProps) => import("react/jsx-runtime").JSX.Element; ++} ++export { SplitButtonLayout }; +diff --git a/build/jetpack-compose/SplitButton/index.js b/build/jetpack-compose/SplitButton/index.js +new file mode 100644 +index 0000000000000000000000000000000000000000..0e723b1e28d30ba0410437eaa5a1a9a0f50e6ad4 +--- /dev/null ++++ b/build/jetpack-compose/SplitButton/index.js +@@ -0,0 +1,47 @@ ++import { jsx as _jsx } from "react/jsx-runtime"; ++import { requireNativeView } from 'expo'; ++import { createViewModifierEventListener } from '../modifiers/utils'; ++const SplitButtonLayoutNativeView = requireNativeView('ExpoUI', 'SplitButtonLayoutView'); ++const LeadingButtonNativeView = requireNativeView('ExpoUI', 'SplitButtonLeadingButtonView'); ++const TrailingButtonNativeView = requireNativeView('ExpoUI', 'SplitButtonTrailingButtonView'); ++const SlotNativeView = requireNativeView('ExpoUI', 'SlotView'); ++function LeadingButton(props) { ++ const { onClick, modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SlotNativeView, { ++ slotName: "leadingButton", ++ children: /*#__PURE__*/ _jsx(LeadingButtonNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps, ++ enabled: props.enabled ?? true, ++ onButtonPressed: onClick ? ()=>onClick() : undefined ++ }) ++ }); ++} ++function TrailingButton(props) { ++ const { onCheckedChange, modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SlotNativeView, { ++ slotName: "trailingButton", ++ children: /*#__PURE__*/ _jsx(TrailingButtonNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps, ++ enabled: props.enabled ?? true, ++ onCheckedChange: onCheckedChange ? ({ nativeEvent: { checked } })=>onCheckedChange(checked) : undefined ++ }) ++ }); ++} ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ function SplitButtonLayout(props) { ++ const { modifiers, ...restProps } = props; ++ return /*#__PURE__*/ _jsx(SplitButtonLayoutNativeView, { ++ modifiers: modifiers, ++ ...modifiers ? createViewModifierEventListener(modifiers) : undefined, ++ ...restProps ++ }); ++} ++SplitButtonLayout.LeadingButton = LeadingButton; ++SplitButtonLayout.TrailingButton = TrailingButton; ++export { SplitButtonLayout }; diff --git a/build/jetpack-compose/TopAppBar/index.d.ts b/build/jetpack-compose/TopAppBar/index.d.ts new file mode 100644 -index 0000000..63afa28 +index 0000000000000000000000000000000000000000..63afa28d56a9b0260de16d32e08bd7405e2c517d --- /dev/null +++ b/build/jetpack-compose/TopAppBar/index.d.ts @@ -0,0 +1,15 @@ @@ -217,7 +491,7 @@ index 0000000..63afa28 +} diff --git a/build/jetpack-compose/TopAppBar/index.js b/build/jetpack-compose/TopAppBar/index.js new file mode 100644 -index 0000000..c6557fe +index 0000000000000000000000000000000000000000..c6557fe6cb559c7d922fc02ce25fa2bf080a50d6 --- /dev/null +++ b/build/jetpack-compose/TopAppBar/index.js @@ -0,0 +1,11 @@ @@ -233,7 +507,7 @@ index 0000000..c6557fe +TopAppBar.NavigationIcon = function NavigationIcon({ children }) { return jsx(Slot, { style: { position: 'absolute' }, slotName: 'navigationIcon', children }); }; +TopAppBar.Actions = function Actions({ children }) { return jsx(Slot, { style: { position: 'absolute' }, slotName: 'actions', children }); }; diff --git a/build/jetpack-compose/index.d.ts b/build/jetpack-compose/index.d.ts -index fa70b35..436a8b7 100644 +index fa70b3574c4c40753b683a98c053caaf63e0385b..96fbbbeaba8396cc9c5cf255c61991bbb2afa899 100644 --- a/build/jetpack-compose/index.d.ts +++ b/build/jetpack-compose/index.d.ts @@ -1,3 +1,4 @@ @@ -241,8 +515,16 @@ index fa70b35..436a8b7 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; export * from './Badge'; +@@ -21,6 +22,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/build/jetpack-compose/index.js b/build/jetpack-compose/index.js -index 2457ed1..fdc8eb1 100644 +index 2457ed1be68b4295442f24a6175fa347cbf80cdb..f394321920fbf1ebb6c7c68bb46690538a05aa0d 100644 --- a/build/jetpack-compose/index.js +++ b/build/jetpack-compose/index.js @@ -1,3 +1,4 @@ @@ -250,8 +532,16 @@ index 2457ed1..fdc8eb1 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; export * from './Badge'; +@@ -21,6 +22,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/src/jetpack-compose/Host/index.tsx b/src/jetpack-compose/Host/index.tsx -index e994f6e..d0ed70a 100644 +index e994f6e1fbad28414207482deeceb542a7b95746..d0ed70a3b9ab0ce5e5c1fd9106c0759f181b6e96 100644 --- a/src/jetpack-compose/Host/index.tsx +++ b/src/jetpack-compose/Host/index.tsx @@ -12,7 +12,7 @@ import { @@ -300,9 +590,157 @@ index e994f6e..d0ed70a 100644 onLayoutContent={onLayoutContent} layoutDirection={ layoutDirection ?? (I18nManager.getConstants().isRTL ? 'rightToLeft' : 'leftToRight') +diff --git a/src/jetpack-compose/SplitButton/index.tsx b/src/jetpack-compose/SplitButton/index.tsx +new file mode 100644 +index 0000000000000000000000000000000000000000..05cba2bfa15b553f74ccacac0b889fae73b13007 +--- /dev/null ++++ b/src/jetpack-compose/SplitButton/index.tsx +@@ -0,0 +1,142 @@ ++import { requireNativeView } from 'expo'; ++import type { ReactNode } from 'react'; ++ ++import type { ModifierConfig, ViewEvent } from '../../types'; ++import type { ButtonColors } from '../Button'; ++import { createViewModifierEventListener } from '../modifiers/utils'; ++ ++export interface SplitButtonLayoutProps { ++ /** ++ * A `SplitButtonLayout.LeadingButton` and a `SplitButtonLayout.TrailingButton`. ++ */ ++ children: ReactNode; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++} ++ ++export interface SplitButtonLeadingButtonProps { ++ /** ++ * Callback that is called when the leading button is clicked. ++ */ ++ onClick?: () => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button. ++ */ ++ children: ReactNode; ++} ++ ++export interface SplitButtonTrailingButtonProps { ++ /** ++ * Whether the trailing button is checked, usually while its menu is expanded. ++ */ ++ checked: boolean; ++ /** ++ * Callback that is called when the checked state changes. ++ */ ++ onCheckedChange?: (checked: boolean) => void; ++ /** ++ * Whether the button is enabled for user interaction. ++ * @default true ++ */ ++ enabled?: boolean; ++ /** ++ * Colors for button elements. ++ */ ++ colors?: ButtonColors; ++ /** ++ * Modifiers for the component. ++ */ ++ modifiers?: ModifierConfig[]; ++ /** ++ * Content to display inside the button, usually a chevron icon. ++ */ ++ children: ReactNode; ++} ++ ++type NativeLeadingButtonProps = Omit & ++ ViewEvent<'onButtonPressed', void>; ++ ++type NativeTrailingButtonProps = Omit & ++ ViewEvent<'onCheckedChange', { checked: boolean }>; ++ ++const SplitButtonLayoutNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonLayoutView' ++); ++const LeadingButtonNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonLeadingButtonView' ++); ++const TrailingButtonNativeView: React.ComponentType = requireNativeView( ++ 'ExpoUI', ++ 'SplitButtonTrailingButtonView' ++); ++const SlotNativeView: React.ComponentType<{ slotName: string; children: ReactNode }> = ++ requireNativeView('ExpoUI', 'SlotView'); ++ ++function LeadingButton(props: SplitButtonLeadingButtonProps) { ++ const { onClick, modifiers, ...restProps } = props; ++ return ( ++ ++ onClick() : undefined} ++ /> ++ ++ ); ++} ++ ++function TrailingButton(props: SplitButtonTrailingButtonProps) { ++ const { onCheckedChange, modifiers, ...restProps } = props; ++ return ( ++ ++ onCheckedChange(checked) : undefined ++ } ++ /> ++ ++ ); ++} ++ ++/** ++ * A Material 3 split button: a leading action button and a trailing toggle button, ++ * usually anchoring a `DropdownMenu` of related actions. ++ */ ++function SplitButtonLayout(props: SplitButtonLayoutProps) { ++ const { modifiers, ...restProps } = props; ++ return ( ++ ++ ); ++} ++ ++SplitButtonLayout.LeadingButton = LeadingButton; ++SplitButtonLayout.TrailingButton = TrailingButton; ++ ++export { SplitButtonLayout }; diff --git a/src/jetpack-compose/TopAppBar/index.tsx b/src/jetpack-compose/TopAppBar/index.tsx new file mode 100644 -index 0000000..ca69824 +index 0000000000000000000000000000000000000000..ca6982443d5711c48e90e79a57b794055efbfcb5 --- /dev/null +++ b/src/jetpack-compose/TopAppBar/index.tsx @@ -0,0 +1,28 @@ @@ -335,7 +773,7 @@ index 0000000..ca69824 +TopAppBar.NavigationIcon = function NavigationIcon({ children }: { children: ReactNode }) { return {children}; }; +TopAppBar.Actions = function Actions({ children }: { children: ReactNode }) { return {children}; }; diff --git a/src/jetpack-compose/index.ts b/src/jetpack-compose/index.ts -index 77fe3d7..3fa0fb9 100644 +index 77fe3d7412fb0ef423e3d7a870676ebb54f07350..08ab59f63c7b40f4727500a37d95bed4f262de82 100644 --- a/src/jetpack-compose/index.ts +++ b/src/jetpack-compose/index.ts @@ -1,3 +1,4 @@ @@ -343,3 +781,11 @@ index 77fe3d7..3fa0fb9 100644 import './MaterialSymbolsAssetsTransformer.fx'; export * from './AlertDialog'; +@@ -22,6 +23,7 @@ export * from './ListItem'; + export * from './RNHostView'; + export * from './DatePicker'; + export * from './SegmentedButton'; ++export * from './SplitButton'; + export * from './Progress'; + export * from './Slider'; + export * from './Spacer'; diff --git a/patches/micromark-extension-llm-math@3.1.0.patch b/patches/micromark-extension-llm-math@3.1.0.patch index 335ce4c11d57..25da4f5f4775 100644 --- a/patches/micromark-extension-llm-math@3.1.0.patch +++ b/patches/micromark-extension-llm-math@3.1.0.patch @@ -235,6 +235,9 @@ diff --git a/dev/lib/math-tex-text.js b/dev/lib/math-tex-text.js diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js --- a/dev/lib/math-text.js +++ b/dev/lib/math-text.js +@@ -10 +10 @@ +-import {markdownLineEnding} from 'micromark-util-character' ++import {markdownLineEnding, markdownLineEndingOrSpace} from 'micromark-util-character' @@ -42,6 +42,13 @@ let size /** @type {Token} */ @@ -299,7 +302,7 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js if ( code === codes.eof || code === codes.space || -@@ -179,9 +207,26 @@ +@@ -179,9 +207,29 @@ // Done! if (size === sizeOpen) { @@ -314,11 +317,14 @@ diff --git a/dev/lib/math-text.js b/dev/lib/math-text.js + if (code !== null && code >= codes.digit0 && code <= codes.digit9) { + return nok(code) + } ++ if (charBeforeClose === codes.comma && !openWhitespace && code !== codes.eof && !markdownLineEndingOrSpace(code) && !/[.,;:)\]}]/.test(String.fromCharCode(code))) { ++ return nok(code) ++ } + // Check if closing sequence has matching whitespace pattern + const closeWhitespace = + charBeforeClose === codes.space || + charBeforeClose === codes.horizontalTab || -+ charBeforeClose === codes.comma ++ (charBeforeClose === codes.comma && openWhitespace) + if (openWhitespace === closeWhitespace) { + effects.exit('mathTextSequence') + effects.exit('mathText') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9625864dff96..ce6a19a974cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,7 +361,7 @@ patchedDependencies: '@effect/vitest@4.0.5': 2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02 '@expo/fingerprint@0.21.5': 8fba80d6bbce46c72de427fe8c7907462d262a0bfe50c6b2b7ee9c4e0c61e5e1 '@expo/metro-config@58.0.14': 90fa980e8b6480631fb2fbb04559834e3c612db40c550cd828f11be297d8d760 - '@expo/ui@58.0.20': 5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e + '@expo/ui@58.0.20': 64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 @@ -380,7 +380,7 @@ patchedDependencies: expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 - micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 + micromark-extension-llm-math@3.1.0: 7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a modal@0.11.0: a2d55a893ecd458a25ec5f84e78a654cf65ff0eff150c022f507a48bd0f243b2 node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 @@ -417,7 +417,7 @@ importers: dependencies: '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron-passkeys': specifier: 0.0.3 version: 0.0.3 @@ -452,8 +452,8 @@ importers: specifier: 4.0.5 version: 4.0.5 electron: - specifier: 44.9.3 - version: 44.9.3(supports-color@8.1.1) + specifier: 44.10.0 + version: 44.10.0(supports-color@8.1.1) electron-store: specifier: ^8.2.0 version: 8.2.0 @@ -500,6 +500,9 @@ importers: apps/marketing: dependencies: + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -539,7 +542,7 @@ importers: version: 58.0.14(@expo/log-box@58.0.12)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@expo/ui': specifier: 58.0.20 - version: 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -913,6 +916,9 @@ importers: character-entities: specifier: 2.0.2 version: 2.0.2 + diff: + specifier: 8.0.3 + version: 8.0.3 e2b: specifier: 2.49.0 version: 2.49.0 @@ -1030,7 +1036,7 @@ importers: version: 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/electron': specifier: 0.1.3 - version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': specifier: 6.18.0 version: 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -8575,8 +8581,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@44.9.3: - resolution: {integrity: sha512-Cv9/yMnZxNWsMlIe8T4v//NKHdUvbeyoTJoXE3BIcRxus2meeFaO9h/P3zB5d4cBXum51IgAYrT2QjpIDWKpJA==} + electron@44.10.0: + resolution: {integrity: sha512-ss4pCfrQKV4TWkuVYtWB4HmeysPZgB96cSYNKYZv1UVCRP4vQMGNW5oopFbvx6Bbfce1XtP5pm5RDuKehp0svQ==} engines: {node: '>= 22.12.0'} hasBin: true @@ -13418,12 +13424,12 @@ snapshots: '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.3 '@clerk/electron-passkeys-win32-x64-msvc': 0.0.3 - '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.9.3(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@clerk/electron@0.1.3(@clerk/electron-passkeys@0.0.3)(electron-store@8.2.0)(electron@44.10.0(supports-color@8.1.1))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@clerk/clerk-js': 6.40.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/react': 6.18.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@clerk/shared': 4.41.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - electron: 44.9.3(supports-color@8.1.1) + electron: 44.10.0(supports-color@8.1.1) react: 19.3.0 tslib: 2.8.1 optionalDependencies: @@ -14564,7 +14570,7 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - ? '@expo/ui@58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' + ? '@expo/ui@58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)' : dependencies: expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 @@ -18359,7 +18365,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@44.9.3(supports-color@8.1.1): + electron@44.10.0(supports-color@8.1.1): dependencies: '@electron-internal/extract-zip': 1.0.5 '@electron/get': 5.1.0(supports-color@8.1.1) @@ -18896,7 +18902,7 @@ snapshots: ? expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) : dependencies: '@expo/plist': 0.10.1 - '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + '@expo/ui': 58.0.20(patch_hash=64559cb60723e19a8a72256d4c78d1315d937a7af3b32d19860ea596f88736ce)(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) expo: 58.0.14(277a83826bb26ba15256633fefeb0bc2) react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) @@ -20357,7 +20363,7 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9): + micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a): dependencies: '@types/katex': 0.16.8 devlop: 1.1.0 @@ -21695,7 +21701,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 mdast-util-math: 3.0.0(patch_hash=afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c)(supports-color@8.1.1) - micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9) + micromark-extension-math: micromark-extension-llm-math@3.1.0(patch_hash=7beb6ac4ba52166286593a494531aace81547e55fdddecf892824c25bd84d15a) unified: 11.0.5 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7b6ae54576c4..8faa288409bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -174,7 +174,7 @@ minimumReleaseAgeExclude: - expo-sharing@58.0.19 - expo-sqlite@58.0.13 - expo-symbols@58.0.4 - - electron@44.9.3 + - electron@44.10.0 - "@anthropic-ai/claude-agent-sdk@0.3.276" - "@shadcn/lint@0.1.5" - cn@0.3.2 diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index ee7c45ada5dd..3c74b6714dde 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -251,6 +251,69 @@ const stageWebClient = Effect.fn("stageWebClient")(function* (source: string, ta } }); +/** Node links libatomic.so.1, which Ubuntu server and container images lack, so t3 carries its own. */ +const bundleLinuxLibatomic = Effect.fn("bundleLinuxLibatomic")(function* (input: { + readonly repoRoot: string; + readonly contentDir: string; + readonly executablePath: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ldd = yield* spawner.string(ChildProcess.make("ldd", [input.executablePath])); + const source = /libatomic\.so\.1 => (\/\S+)/.exec(ldd)?.[1]; + if (source === undefined) { + return yield* new CliArchiveInputMissingError({ + inputPath: "libatomic.so.1", + hint: `ldd did not resolve it for ${input.executablePath}. Install libatomic1 on the build host.`, + }); + } + yield* fs.makeDirectory(path.join(input.contentDir, "lib")); + yield* fs.copyFile(source, path.join(input.contentDir, "lib/libatomic.so.1")); + + // libatomic is GPL-3.0-or-later WITH GCC-exception-3.1, so its terms and source travel with it. + const owner = yield* spawner.string( + ChildProcess.make("dpkg-query", ["-S", yield* fs.realPath(source)]), + ); + // Keep the arch qualifier so a second installed arch cannot add its own line. + const [, qualifiedPkg, pkg] = /^(([^:\s]+)(?::[^:\s]+)?): /.exec(owner) ?? []; + const versions = qualifiedPkg + ? yield* spawner.string( + ChildProcess.make("dpkg-query", [ + "-W", + "-f=${Version} ${source:Package} ${source:Version}", + qualifiedPkg, + ]), + ) + : ""; + const [version, sourcePackage, sourceVersion] = versions.trim().split(" "); + if (!pkg || !version || !sourcePackage || !sourceVersion) { + return yield* new CliArchiveInputMissingError({ + inputPath: `dpkg package for ${source}`, + hint: "The archive copies libatomic's license and source reference from its Ubuntu package.", + }); + } + const header = [ + `libatomic.so.1 is copied from the Ubuntu package ${pkg} ${version}.`, + `Source: https://launchpad.net/ubuntu/+source/${sourcePackage}/${sourceVersion}`, + ].join("\n"); + // Vendored because CI runner images strip /usr/share/doc. + const license = yield* fs.readFileString( + path.join(input.repoRoot, "scripts/licenses/libatomic.LICENSE"), + ); + yield* fs.writeFileString( + path.join(input.contentDir, "lib/libatomic.so.1.LICENSE"), + `${header}\n\n${license}`, + ); + + // RUNPATH resolves through symlinks and is not inherited by child processes, unlike LD_LIBRARY_PATH. + yield* runCommand( + ChildProcess.make("patchelf", ["--set-rpath", "$ORIGIN/lib", input.executablePath]), + "patchelf --set-rpath", + ); + yield* Effect.log(`[cli-archive] Bundled ${source} as lib/libatomic.so.1.`); +}); + const MacSigningConfig = Config.all({ identity: Config.String("T3CODE_CLI_MAC_SIGN_IDENTITY").pipe(Config.option), appleApiKey: Config.String("APPLE_API_KEY").pipe(Config.option), @@ -444,11 +507,6 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { builtExecutable, `Run \`node apps/server/scripts/cli.ts build-exe${targetKey === hostKey ? "" : ` --target ${targetKey}`}\` first.`, ); - // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 - if (input.platform === "linux" && input.arch === "arm64") { - const bytes = yield* fs.readFile(builtExecutable); - yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); - } yield* requireInput(path.join(webClient, "index.html"), "Run `vp run --filter t3 build` first."); yield* requireInput( resourceMonitorDir, @@ -473,6 +531,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { }); const executablePath = path.join(contentDir, executableName); + if (input.platform === "linux") { + yield* bundleLinuxLibatomic({ repoRoot, contentDir, executablePath }); + } + // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 + if (input.platform === "linux" && input.arch === "arm64") { + const bytes = yield* fs.readFile(executablePath); + yield* Effect.try(() => assertLinuxArm64PageCompatibility(bytes)); + } if (input.platform === "mac") { yield* signMacArchiveContents({ repoRoot, contentDir, executablePath }); } else if (input.platform === "win") { diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index 8dd98225cd07..4c7edd476335 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -13,6 +13,8 @@ import * as NodeCrypto from "node:crypto"; import * as NodeEvents from "node:events"; import * as NodeHttp from "node:http"; +import { npmReleaseDatabaseMigration } from "@t3tools/shared/cliRelease"; + import { buildNpmLauncher } from "./build-npm-launcher.ts"; const VERSION = "1.2.3"; @@ -118,6 +120,7 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { assert.deepStrictEqual(manifest.bin, { t3: "./bin/t3.mjs" }); assert.isUndefined(manifest.dependencies); assert.isUndefined(manifest.optionalDependencies); + assert.isAbove(npmReleaseDatabaseMigration(manifest) ?? 0, 0); const installed = path.join(root, "installed"); yield* fs.makeDirectory(installed); @@ -192,6 +195,118 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { VERSION, ]); + // A peer's executable and sidecar stay available after every later rename or removal, + // even when it publishes after our missing-marker read. + const staleHome = path.join(root, "stale-home"); + const staleDir = path.join(staleHome, "runtime/versions", VERSION); + const peerDir = path.join(root, "peer-runtime"); + yield* fs.makeDirectory(staleDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleDir, "t3"), ""); + yield* fs.makeDirectory(peerDir); + yield* fs.writeFileString(path.join(peerDir, "t3"), '#!/bin/sh\necho "peer $*"\nexit 7\n'); + yield* fs.chmod(path.join(peerDir, "t3"), 0o755); + yield* fs.writeFileString(path.join(peerDir, ".install-complete"), `${VERSION}\n`); + yield* fs.writeFileString(path.join(peerDir, "sidecar"), "peer sidecar"); + const peerChecks = path.join(root, "peer-checks.txt"); + // The peer publishes after the launcher's rename fails and it reads the marker. + const peerHook = path.join(root, "peer-publishes.mjs"); + yield* fs.writeFileString( + peerHook, + `import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +const dir = ${JSON.stringify(staleDir)}; +const { readFileSync, renameSync, rmSync } = fs; +let checking = false; +let published = false; +const checkPeer = () => { + const child = spawnSync(dir + "/t3", ["--version"], { encoding: "utf8" }); + let sidecar; + try { + sidecar = readFileSync(dir + "/sidecar", "utf8"); + } catch (error) { + sidecar = error.code; + } + fs.appendFileSync(${JSON.stringify(peerChecks)}, + [child.status, child.error?.code ?? child.stdout.trim(), sidecar].join("|") + "\\n"); +}; +fs.renameSync = (from, to) => { + try { + return renameSync(from, to); + } catch (error) { + checking ||= to === dir; + throw error; + } finally { + if (published) checkPeer(); + } +}; +fs.rmSync = (...args) => { + try { + return rmSync(...args); + } finally { + if (published) checkPeer(); + } +}; +fs.readFileSync = (file, ...rest) => { + try { + return readFileSync(file, ...rest); + } finally { + if (checking && file === dir + "/.install-complete") { + checking = false; + rmSync(dir, { recursive: true }); + renameSync(${JSON.stringify(peerDir)}, dir); + published = true; + checkPeer(); + } + } +}; +`, + ); + const recovered = yield* run( + process.execPath, + ["--import", peerHook, launcher, "--version"], + { cwd: root, env: { ...env, T3CODE_HOME: staleHome } }, + ); + const checks = (yield* fs.readFileString(peerChecks)).trim().split("\n"); + assert.isAtLeast(checks.length, 2); + assert.deepStrictEqual([...new Set(checks)], ["7|peer --version|peer sidecar"]); + assert.equal(recovered.stdout.trim(), "peer --version", recovered.stderr); + assert.equal(recovered.exitCode, 7); + assert.deepStrictEqual(yield* fs.readDirectory(path.join(staleHome, "runtime/versions")), [ + VERSION, + ]); + + // While another launch holds the lock, a stale folder is left alone and the error says + // how to clear a lock left by a crash. + const staleOnlyHome = path.join(root, "stale-only-home"); + const staleOnlyDir = path.join(staleOnlyHome, "runtime/versions", VERSION); + yield* fs.makeDirectory(staleOnlyDir, { recursive: true }); + yield* fs.writeFileString(path.join(staleOnlyDir, "t3"), "incomplete"); + yield* fs.makeDirectory(`${staleOnlyDir}.lock`); + const locked = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal(locked.exitCode, 1); + assert.include(locked.stderr, `delete ${staleOnlyDir}.lock`); + assert.equal(yield* fs.readFileString(path.join(staleOnlyDir, "t3")), "incomplete"); + + // Without a peer, the stale folder is replaced by our own download. + yield* fs.remove(`${staleOnlyDir}.lock`, { recursive: true }); + const replaced = yield* run(process.execPath, [launcher, "--version"], { + cwd: root, + env: { ...env, T3CODE_HOME: staleOnlyHome }, + }); + assert.equal( + replaced.stdout.trim(), + `stub --version launcher=${launcher}`, + replaced.stderr, + ); + assert.equal(replaced.exitCode, 7); + assert.deepStrictEqual( + yield* fs.readDirectory(path.join(staleOnlyHome, "runtime/versions")), + [VERSION], + ); + release.corruptChecksum(); const corrupt = yield* run(process.execPath, [launcher, "--version"], { cwd: root, diff --git a/scripts/build-npm-launcher.ts b/scripts/build-npm-launcher.ts index df1cc488814a..bae124fb008a 100644 --- a/scripts/build-npm-launcher.ts +++ b/scripts/build-npm-launcher.ts @@ -30,8 +30,8 @@ export class NpmLauncherCommandFailedError extends Schema.TaggedError=20.11" }, + t3code: { databaseMigration }, }; } +/** Highest server migration id, from the `NNN_Name.ts` files the server registers. */ +const latestServerMigrationId = Effect.fn("latestServerMigrationId")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* path.fromFileUrl( + new URL("../apps/server/src/persistence/Migrations/", import.meta.url), + ); + const ids = (yield* fs.readDirectory(dir)).flatMap((name) => { + const id = /^(\d+)_\w+\.ts$/.exec(name)?.[1]; + return id === undefined ? [] : [Number(id)]; + }); + return Math.max(0, ...ids); +}); + export const buildNpmLauncher = Effect.fn("buildNpmLauncher")(function* (input: { readonly version: string; readonly outputDir: string; @@ -64,7 +79,9 @@ export const buildNpmLauncher = Effect.fn("buildNpmLauncher")(function* (input: yield* fs.makeDirectory(path.join(stageDir, "dist")); yield* fs.writeFileString( path.join(stageDir, "package.json"), - `${yield* encodePackageJson(npmLauncherPackageManifest(input.version))}\n`, + `${yield* encodePackageJson( + npmLauncherPackageManifest(input.version, yield* latestServerMigrationId()), + )}\n`, ); yield* fs.copyFile(path.join(sourceDir, "t3.mjs"), path.join(stageDir, "bin/t3.mjs")); yield* fs.chmod(path.join(stageDir, "bin/t3.mjs"), 0o755); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index 25630728e3d6..b854cf0a4e53 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -1,7 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off // Runs CI's fast checks for this branch's changes: format and lint of the // changed files, knip, generated GraphQL operations, typecheck of the affected -// packages and their dependents, and the test files beside the changed files. +// packages and their dependents, the test files beside the changed files, and +// the cross-file checks those miss (agent context budget, React Compiler +// coverage of the changed client files). // A dependency change also gets the unused-patch install check below. CI still // runs the full test suites and the jobs listed in the summary. // @@ -138,6 +140,52 @@ for (const file of code) { if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(NodePath.join(dir, entry)); } } +// It reads files other than the ones beside it, so no changed file selects it. +if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts"); + +// React Compiler coverage compiles every client source file, so run only the +// changed files' cases, in one extra step: a -t filter in the main test step +// would drop every other test. An app is skipped when the main step already +// runs its coverage file unfiltered. File filters mirror each coverage test. +const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const compilerApps = [ + { + test: "apps/web/src/reactCompilerCoverage.test.ts", + root: "apps/web/src/", + // Test names keep the repo-relative path. + name: (file: string) => file, + compiled: (file: string) => + /\.tsx?$/.test(file) && !/\.(test|browser)\.tsx?$/.test(file) && !file.endsWith(".gen.ts"), + // Web has only per-file cases. + always: [], + }, + { + test: "apps/mobile/src/reactCompilerCoverage.test.ts", + root: "apps/mobile/src/", + name: (file: string) => file.slice("apps/mobile/".length), + compiled: (file: string) => /\.tsx?$/.test(file) && !/\.(test\.tsx?|d\.ts)$/.test(file), + // The Metro, cache, and stale-allowlist cases check more than one file, deletions included. + always: ["^(?!.*compiles )"], + }, +].flatMap(({ test, root: appRoot, name, compiled, always }) => { + const changed = files.filter((file) => file.startsWith(appRoot) && compiled(file)); + if (changed.length === 0 || tests.has(test)) return []; + const names = changed + .filter((file) => present.includes(file)) + .map((file) => `compiles ${escapeRegExp(name(file))}$`); + const pattern = [...names, ...always]; + return pattern.length > 0 ? [{ test, names, pattern }] : []; +}); +const compilerCoverage = compilerApps.length > 0 && { + name: `react compiler (${compilerApps.reduce((count, app) => count + app.names.length, 0)} files)`, + args: [ + "test", + "run", + ...compilerApps.map((app) => app.test), + "-t", + compilerApps.flatMap((app) => app.pattern).join("|"), + ], +}; // knip:check cannot be scoped to a package. Any code change can leave a file, // export, or dependency unused (removing the last import of a helper does), so @@ -255,33 +303,42 @@ const steps = [ name: `test (${tests.size} files)`, args: ["test", "run", "--passWithNoTests", ...tests], }, + compilerCoverage, ].filter((step) => step !== false); // Output is piped, so keep the colors a terminal would get. const env = process.stdout.isTTY ? { ...process.env, FORCE_COLOR: "1" } : process.env; +const runStep = (step: (typeof steps)[number]) => + new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { + const started = performance.now(); + const finish = (ok: boolean, output?: Buffer) => { + const seconds = ((performance.now() - started) / 1000).toFixed(1); + console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); + if (output) process.stdout.write(output); + resolve({ name: step.name, ok, seconds }); + }; + // In-process steps print their own output. The patch check is first and + // takes about a second, so the commands start right after it. + if ("run" in step) return finish(step.run()); + const output: Array = []; + const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + // A failed spawn emits "error", then "close" with a nonzero code. + child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); + child.on("close", (status) => finish(status === 0, Buffer.concat(output))); + }); + +// Vitest runs started together race to rebuild node_modules/.vitest-cache after +// an install (ENOTEMPTY), so they run one after another. +let previousVitest: Promise = Promise.resolve(); const results = await Promise.all( - steps.map( - (step) => - new Promise<{ name: string; ok: boolean; seconds: string }>((resolve) => { - const started = performance.now(); - const finish = (ok: boolean, output?: Buffer) => { - const seconds = ((performance.now() - started) / 1000).toFixed(1); - console.log(`\n> vp ${step.name} ${ok ? "pass" : "FAIL"} ${seconds}s`); - if (output) process.stdout.write(output); - resolve({ name: step.name, ok, seconds }); - }; - // In-process steps print their own output. The patch check is first and - // takes about a second, so the commands start right after it. - if ("run" in step) return finish(step.run()); - const output: Array = []; - const child = NodeChildProcess.spawn("vp", step.args, { cwd: root, env }); - child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr.on("data", (chunk: Buffer) => output.push(chunk)); - // A failed spawn emits "error", then "close" with a nonzero code. - child.on("error", (error) => output.push(Buffer.from(`${error.message}\n`))); - child.on("close", (status) => finish(status === 0, Buffer.concat(output))); - }), - ), + steps.map((step) => { + if (!("args" in step) || step.args[0] !== "test") return runStep(step); + const result = previousVitest.then(() => runStep(step)); + previousVitest = result; + return result; + }), ); const summary = ["check:changed summary"]; diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 895144079fc5..d522bfdb1579 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -412,6 +412,37 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("expands a home-relative T3 home the way the server does", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homeFor = (t3Home: string) => + createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }).pipe( + Effect.provideService(HostProcess.HomeDirectory, "/home/dev"), + Effect.map((env) => env.T3CODE_HOME), + ); + + assert.equal(yield* homeFor("~/t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~"), "/home/dev"); + assert.equal(yield* homeFor("~\\t3-dev"), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor(" ~/t3-dev "), path.join("/home/dev", "t3-dev")); + assert.equal(yield* homeFor("~someone/t3-dev"), path.resolve("~someone/t3-dev")); + assert.equal(yield* homeFor("relative/t3-dev"), path.resolve("relative/t3-dev")); + assert.equal(yield* homeFor("/tmp/t3-dev"), path.resolve("/tmp/t3-dev")); + }), + ); + it.effect("rejects a specific non-loopback --host for browser dev modes", () => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 0495c513dc68..5903cb7686e9 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -272,6 +272,12 @@ const resolveBaseDir = Effect.fn("dev-runner.resolveBaseDir")(function* ( const path = yield* Path.Path; const configured = baseDir?.trim(); + if (configured === "~") { + return yield* HostProcess.HomeDirectory; + } + if (configured?.startsWith("~/") || configured?.startsWith("~\\")) { + return path.join(yield* HostProcess.HomeDirectory, configured.slice(2)); + } if (configured) { return path.resolve(configured); } diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts new file mode 100644 index 000000000000..0c55b082dcfb --- /dev/null +++ b/scripts/generate-release-notes.test.ts @@ -0,0 +1,65 @@ +import { assert, it } from "@effect/vitest"; + +import * as Option from "effect/Option"; + +import { + countContributors, + isReleaseNoteCommit, + summaryText, + type PullRequest, +} from "./generate-release-notes.ts"; + +const pullRequest = (author: PullRequest["author"]): PullRequest => ({ + body: "", + additions: 1, + deletions: 1, + changedFiles: 1, + author, +}); + +it("counts each person once and leaves out bots", () => { + const commits = [ + { subject: "fix(web): one (#1)", authorName: "Ada Lovelace" }, + { subject: "fix(web): two (#2)", authorName: "Ada Lovelace" }, + { subject: "chore: bump (#3)", authorName: "dependabot[bot]" }, + { subject: "fix: direct push by a PR author", authorName: "Ada Lovelace" }, + { subject: "fix: direct push by someone else", authorName: "Grace Hopper" }, + { subject: "chore: direct push by a bot", authorName: "t3-code[bot]" }, + { subject: "fix(web): four (#4)", authorName: "Linus T" }, + { subject: "fix: direct push by a PR author with no profile name", authorName: "Linus T" }, + { subject: "fix(web): five, whose PR did not resolve (#5)", authorName: "Ken T" }, + ]; + const pullRequests = new Map([ + [1, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [2, pullRequest({ __typename: "User", login: "ada", name: "Ada Lovelace" })], + [3, pullRequest({ __typename: "Bot", login: "dependabot" })], + [4, pullRequest({ __typename: "User", login: "linus", name: null })], + ]); + + assert.equal(countContributors(commits, pullRequests), 4); +}); + +it("leaves mobile-only scopes out and keeps shared ones", () => { + const subjects = { + "fix(mobile): a": false, + "feat(android): b": false, + "fix(ios)!: c": false, + "fix(web,mobile): d": true, + "feat(mobile,web): f": true, + "fix(server): e": true, + }; + for (const [subject, kept] of Object.entries(subjects)) { + assert.equal(isReleaseNoteCommit({ subject, authorName: "" }), kept, subject); + } +}); + +it("uses model text only from a completed response", () => { + const output = [ + { type: "message", content: [{ type: "output_text", text: " ## Highlights\nX " }] }, + ]; + assert.deepStrictEqual( + summaryText({ status: "completed", output }), + Option.some("## Highlights\nX"), + ); + assert.deepStrictEqual(summaryText({ status: "incomplete", output }), Option.none()); +}); diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts new file mode 100644 index 000000000000..909247dae126 --- /dev/null +++ b/scripts/generate-release-notes.ts @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/cli"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +// Release notes cover web, desktop, and server. Mobile ships its own store notes. +const MOBILE_SCOPES = new Set(["mobile", "android", "ios"]); +const COMMIT_SCOPES = /^[a-z]+\(([^)]*)\)!?:/i; +const PULL_REQUEST_NUMBER = /\(#(\d+)\)$/; +const PULL_REQUEST_BODY_LIMIT = 300; +const SUMMARY_MODEL = "gpt-6.1-terra"; + +const SUMMARY_INSTRUCTIONS = `You write release notes for T3 Code, a GUI for coding agents (web and desktop apps backed by a server; providers like Codex, Claude, Cursor, OpenCode). + +These notes cover the web app, desktop app, and server only. The mobile app has its own release notes, so leave out anything only about mobile, iOS, or Android. + +You get the commit subjects merged since the last release, oldest first. Most end with a PR number like (#1234). Each commit shows lines added and removed and files changed, then a trimmed PR description on the next line starting with ">". Use the descriptions to understand what changed for users and why it matters. Line counts hint at size, but a small change can still matter most. + +Output Markdown in exactly this shape and nothing else: + +## Highlights +Up to 4 short sentences. Name only the new features a user would care about most. If no new feature matters to users, write one sentence about the most important fix. Each feature gets a few words that make the benefit clear, not a description of how it works or where the button is. + +## Fixes +- Up to 10 bullets, one fix each, under 10 words, ending with the PR number like (#123). Fewer is fine. Never pad. +- Each bullet names what was broken and says it works now. A user who hit the bug should recognize it. +- Only list fixes from fix commits. New abilities go in Highlights or nowhere. Say "again" only if the commit says it. + +How to write: +- Pick only significant, user-facing changes. Leave out visual polish (focus rings, spacing, colors), layout tweaks, refactors, tests, CI, build, docs, deps, internal tooling, and performance or efficiency work a user would not feel. +- Include Labs and beta features, with "(beta)" after the feature. Leave out work marked internal testing or dev-only. +- Describe each feature as it is at the end of the range. If a beta feature became standard later in the range, it is not beta. +- Say what the user gets, as short as possible. Cut details, conditions, and mechanics. +- No defaults or rollout state ("on by default", "now available", "behind a flag"). +- For lists of file types or formats, use common extensions: "previews for .docx, .ttf, .sqlite, .zip and more". +- No internal names, env vars, file paths, or code terms. +- No em dashes. No hype words. Do not invent anything that is not in the input. + +Examples of the tone: +- Bad: "Cmd+K now finds the right thread first, and it searches message text too." Good: "Much better Cmd+K search." +- Bad: "You can share a thread with a guest who watches it live and sends messages that you approve." Good: "Share threads with guests, safely." +- Bad: "Triage issues from the command palette." Good: "Fix and debug T3 Code itself with a new 'triage' command." +- Bad: "Settings and Usage open as large modals over your thread." Good: leave it out. +- Bad: "Cmd/Ctrl+W closes tabs safely (#25860)" Good: "Cmd/Ctrl+W no longer closes the whole window (#25860)"`; + +class EmptyReleaseRangeError extends Schema.TaggedError()( + "EmptyReleaseRangeError", + { range: Schema.String }, +) {} + +interface ReleaseCommit { + readonly subject: string; + readonly authorName: string; +} + +const PullRequest = Schema.Struct({ + body: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + changedFiles: Schema.Number, + author: Schema.NullOr( + Schema.Struct({ + __typename: Schema.String, + login: Schema.String, + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); +export type PullRequest = typeof PullRequest.Type; + +const PullRequestQueryResponse = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)), + }), +}); + +const GeneratedNotes = Schema.Struct({ body: Schema.String }); + +const SummaryResponse = Schema.Struct({ + status: Schema.String, + output: Schema.Array( + Schema.Struct({ + type: Schema.String, + content: Schema.optional( + Schema.Array(Schema.Struct({ type: Schema.String, text: Schema.optional(Schema.String) })), + ), + }), + ), + usage: Schema.optional( + Schema.Struct({ input_tokens: Schema.Number, output_tokens: Schema.Number }), + ), +}); + +const pullRequestNumber = (subject: string) => { + const match = PULL_REQUEST_NUMBER.exec(subject); + return match ? Number(match[1]) : undefined; +}; + +/** Drops a commit only when every scope is mobile, so `fix(web,mobile)` stays. */ +export function isReleaseNoteCommit(commit: ReleaseCommit): boolean { + const scopes = COMMIT_SCOPES.exec(commit.subject)?.[1]?.split(","); + return !scopes?.every((scope) => MOBILE_SCOPES.has(scope.trim().toLowerCase())); +} + +/** Keeps the prose of a PR body: no images, tables, headings, HTML, or verification sections. */ +function trimPullRequestBody(body: string, limit = PULL_REQUEST_BODY_LIMIT): string { + const [prose = ""] = body.split(/\n#+\s*(?:Verification|Council|Test|Testing|Screenshots)\b/i); + const text = prose + .split("\n") + .filter((line) => line.trim() !== "" && !/^\s*[!|#<]/.test(line)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return text.length > limit ? `${text.slice(0, limit)}...` : text; +} + +function buildSummaryInput( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): string { + return commits + .flatMap(({ subject }) => { + const number = pullRequestNumber(subject); + const pullRequest = number === undefined ? undefined : pullRequests.get(number); + if (!pullRequest) return [subject]; + const line = `${subject} [+${pullRequest.additions} -${pullRequest.deletions}, ${pullRequest.changedFiles} files]`; + const body = trimPullRequestBody(pullRequest.body); + return body ? [line, ` > ${body}`] : [line]; + }) + .join("\n"); +} + +/** PR authors without bots, plus authors of direct pushes who have no PR in the range. */ +export function countContributors( + commits: ReadonlyArray, + pullRequests: ReadonlyMap, +): number { + const people = new Set(); + // A PR's squash commit carries its author's git name, which may differ from the profile name. + const prAuthorNames = new Set(); + const directAuthors: string[] = []; + for (const commit of commits) { + const number = pullRequestNumber(commit.subject); + const author = number === undefined ? undefined : pullRequests.get(number)?.author; + if (!author) { + directAuthors.push(commit.authorName); + continue; + } + if (author.__typename === "Bot" || author.login.endsWith("[bot]")) continue; + people.add(author.login); + prAuthorNames.add(commit.authorName); + if (author.name) prAuthorNames.add(author.name); + } + for (const name of directAuthors) { + if (!name.endsWith("[bot]") && !prAuthorNames.has(name)) people.add(name); + } + return people.size; +} + +/** No date here: the app shows how long ago a release shipped, and GitHub shows the date. */ +function buildReleaseHeader(version: string, changes: number, contributors: number): string { + const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + return `**v${version.replace(/^v/, "")}** ยท ${plural(changes, "change")} from ${plural(contributors, "contributor")}`; +} + +/** The summary leads; GitHub's full list folds under it. Clients cut the body at `
    `. */ +function assembleReleaseNotes(header: string, summary: string, fullChangelog: string): string { + return `${header}\n\n${summary.trim()}\n\n
    \nFull changelog\n\n${fullChangelog.trim()}\n\n
    \n`; +} + +const run = Effect.fn("generateReleaseNotes.run")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner.string(ChildProcess.make(command, args)); +}); + +const decodeJson = (schema: S) => + Schema.decodeUnknownEffect(Schema.fromJsonString(schema)); + +const readCommits = Effect.fn("generateReleaseNotes.readCommits")(function* ( + previousTag: string, + ref: string, +) { + const log = yield* run("git", [ + "log", + "--reverse", + "--format=%s%x1f%an", + `${previousTag}..${ref}`, + ]); + return log + .split("\n") + .filter((line) => line !== "") + .map((line): ReleaseCommit => { + const [subject = "", authorName = ""] = line.split("\x1f"); + return { subject, authorName }; + }); +}); + +const readPullRequests = Effect.fn("generateReleaseNotes.readPullRequests")(function* ( + repository: string, + numbers: ReadonlyArray, +) { + const [owner, name] = repository.split("/"); + const pullRequests = new Map(); + for (let start = 0; start < numbers.length; start += 100) { + const fields = numbers + .slice(start, start + 100) + .map( + (number) => + `pr${number}: pullRequest(number: ${number}) { body additions deletions changedFiles author { __typename login ... on User { name } } }`, + ) + .join("\n"); + const query = `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields} } }`; + const response = yield* decodeJson(PullRequestQueryResponse)( + yield* run("gh", [ + "api", + "graphql", + "-f", + `query=${query}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + ]), + ); + for (const [alias, pullRequest] of Object.entries(response.data.repository)) { + if (pullRequest) pullRequests.set(Number(alias.slice(2)), pullRequest); + } + } + return pullRequests; +}); + +const readFullChangelog = Effect.fn("generateReleaseNotes.readFullChangelog")(function* (options: { + readonly repository: string; + readonly tag: string; + readonly ref: string; + readonly previousTag: string; +}) { + const response = yield* run("gh", [ + "api", + `repos/${options.repository}/releases/generate-notes`, + "-f", + `tag_name=${options.tag}`, + "-f", + `target_commitish=${options.ref}`, + "-f", + `previous_tag_name=${options.previousTag}`, + ]); + return (yield* decodeJson(GeneratedNotes)(response)).body; +}); + +export function summaryText(response: typeof SummaryResponse.Type): Option.Option { + // An incomplete response can carry partial text; publishing it would cut the notes short. + if (response.status !== "completed") return Option.none(); + const text = response.output + .flatMap((item) => item.content ?? []) + .flatMap((part) => (part.type === "output_text" && part.text ? [part.text] : [])) + .join("") + .trim(); + return text === "" ? Option.none() : Option.some(text); +} + +const summarize = Effect.fn("generateReleaseNotes.summarize")(function* (input: string) { + const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.post("https://api.openai.com/v1/responses").pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.bodyJson({ + model: SUMMARY_MODEL, + reasoning: { effort: "low" }, + instructions: SUMMARY_INSTRUCTIONS, + input: `Commits:\n${input}`, + }), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(SummaryResponse)), + Effect.timeout("3 minutes"), + ); + yield* Effect.logInfo("release summary generated").pipe( + Effect.annotateLogs({ + model: SUMMARY_MODEL, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + }), + ); + return summaryText(response); +}); + +const command = Command.make( + "generate-release-notes", + { + repository: Flag.String("repository").pipe(Flag.withDescription("GitHub owner/name.")), + tag: Flag.String("tag").pipe(Flag.withDescription("Tag being released.")), + ref: Flag.String("ref").pipe(Flag.withDescription("Commit being released.")), + previousTag: Flag.String("previous-tag").pipe(Flag.withDescription("Previous release tag.")), + version: Flag.String("version").pipe(Flag.withDescription("Version shown in the header.")), + output: Flag.String("output").pipe(Flag.withDescription("File to write the notes to.")), + }, + ({ repository, tag, ref, previousTag, version, output }) => + Effect.gen(function* () { + // The header counts every commit in the range; the summary skips mobile-only work. + const allCommits = yield* readCommits(previousTag, ref); + // The spawner ignores exit codes, so a failed git log shows up as an empty range. + if (allCommits.length === 0) { + return yield* new EmptyReleaseRangeError({ range: `${previousTag}..${ref}` }); + } + const commits = allCommits.filter(isReleaseNoteCommit); + const numbers = [ + ...new Set(allCommits.flatMap(({ subject }) => pullRequestNumber(subject) ?? [])), + ]; + const [pullRequests, fullChangelog] = yield* Effect.all( + [ + readPullRequests(repository, numbers), + readFullChangelog({ repository, tag, ref, previousTag }), + ], + { concurrency: "unbounded" }, + ); + const header = buildReleaseHeader( + version, + allCommits.length, + countContributors(allCommits, pullRequests), + ); + // With no summary, write nothing: the release falls back to GitHub's generated notes. + const summary = + commits.length === 0 + ? Option.none() + : yield* summarize(buildSummaryInput(commits, pullRequests)); + if (Option.isNone(summary)) { + return yield* Effect.logWarning("no release summary, leaving GitHub's notes in place"); + } + const fs = yield* FileSystem.FileSystem; + // Publish uses the file when it exists, so a partial write must never land at `output`. + yield* fs.writeFileString( + `${output}.tmp`, + assembleReleaseNotes(header, summary.value, fullChangelog), + ); + yield* fs.rename(`${output}.tmp`, output); + yield* Effect.logInfo("release notes written").pipe( + Effect.annotateLogs({ output, commits: commits.length }), + ); + }), +).pipe( + Command.withDescription("Write release notes with a model summary over GitHub's full list."), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide( + Layer.mergeAll( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + FetchHttpClient.layer, + ), + ), + NodeRuntime.runMain, + ); +} diff --git a/scripts/licenses/libatomic.LICENSE b/scripts/licenses/libatomic.LICENSE new file mode 100644 index 000000000000..bea77366d7f5 --- /dev/null +++ b/scripts/licenses/libatomic.LICENSE @@ -0,0 +1,753 @@ +libatomic is part of GCC. Copyright (C) Free Software Foundation, Inc. +It is licensed under GPL-3.0-or-later WITH GCC-exception-3.1. Both texts follow. + + +GCC RUNTIME LIBRARY EXCEPTION + +Version 3.1, 31 March 2009 + +Copyright (C) 2009 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This GCC Runtime Library Exception ("Exception") is an additional +permission under section 7 of the GNU General Public License, version +3 ("GPLv3"). It applies to a given file (the "Runtime Library") that +bears a notice placed by the copyright holder of the file stating that +the file is governed by GPLv3 along with this Exception. + +When you use GCC to compile a program, GCC may combine portions of +certain GCC header files and runtime libraries with the compiled +program. The purpose of this Exception is to allow compilation of +non-GPL (including proprietary) programs to use, in this way, the +header files and runtime libraries covered by this Exception. + +0. Definitions. + +A file is an "Independent Module" if it either requires the Runtime +Library for execution after a Compilation Process, or makes use of an +interface provided by the Runtime Library, but is not otherwise based +on the Runtime Library. + +"GCC" means a version of the GNU Compiler Collection, with or without +modifications, governed by version 3 (or a specified later version) of +the GNU General Public License (GPL) with the option of using any +subsequent versions published by the FSF. + +"GPL-compatible Software" is software whose conditions of propagation, +modification and use would permit combination with GCC in accord with +the license of GCC. + +"Target Code" refers to output from any compiler for a real or virtual +target processor architecture, in executable form or suitable for +input to an assembler, loader, linker and/or execution +phase. Notwithstanding that, Target Code does not include data in any +format that is used as a compiler intermediate representation, or used +for producing a compiler intermediate representation. + +The "Compilation Process" transforms code entirely represented in +non-intermediate languages designed for human-written code, and/or in +Java Virtual Machine byte code, into Target Code. Thus, for example, +use of source code generators and preprocessors need not be considered +part of the Compilation Process, since the Compilation Process can be +understood as starting with the output of the generators or +preprocessors. + +A Compilation Process is "Eligible" if it is done using GCC, alone or +with other GPL-compatible software, or if it is done without using any +work based on GCC. For example, using non-GPL-compatible Software to +optimize any GCC intermediate representations would not qualify as an +Eligible Compilation Process. + +1. Grant of Additional Permission. + +You have permission to propagate a work of Target Code formed by +combining the Runtime Library with Independent Modules, even if such +propagation would otherwise violate the terms of GPLv3, provided that +all Target Code was generated by Eligible Compilation Processes. You +may then convey such a combination under terms of your choice, +consistent with the licensing of the Independent Modules. + +2. No Weakening of GCC Copyleft. + +The availability of this Exception does not imply any general +presumption that third-party software is unaffected by the copyleft +requirements of the license of GCC. + + + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/scripts/notify-discord-release.test.ts b/scripts/notify-discord-release.test.ts index 6e57ffb56a7e..84a43869be6a 100644 --- a/scripts/notify-discord-release.test.ts +++ b/scripts/notify-discord-release.test.ts @@ -132,6 +132,15 @@ it("preserves custom notes and links outside generated GitHub pull request entri ); }); +it("posts a generated summary without its folded full list", () => { + const summary = "**v1.2.3** ยท 4 changes\n\n## Fixes\n- Stop works again (#1)"; + const releaseNotes = `${summary}\n\n
    \nFull changelog\n\n* fix: raw by @someone in https://github.com/pingdotgg/t3code/pull/1\n\n
    \n`; + assert.equal( + payloadDescription(buildDiscordReleaseAnnouncement({ ...nightlyAnnouncement, releaseNotes })), + `${intro}\n\n**v1.2.3** ยท 4 changes\n\n## Fixes\n- Stop works again ([#1](https://github.com/pingdotgg/t3code/pull/1))\n\n[Full changelog](${nightlyAnnouncement.releaseUrl.href})`, + ); +}); + it("falls back when notes are missing, empty, or whitespace", () => { const fallback = buildDiscordReleaseAnnouncement(nightlyAnnouncement); assert.equal(payloadDescription(fallback), intro); diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 0eb3bf348530..29d94561c8fb 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -135,6 +135,12 @@ const suppressMentions = (text: string) => const escapeLinkLabel = (text: string) => text.replace(/[\\[\]]/g, "\\$&"); +/** GitHub links `(#123)` on the release page; Discord needs the link written out. */ +function linkPullRequestNumbers(notes: string, releaseUrl: URL) { + const repositoryUrl = releaseUrl.href.split("/releases/")[0]; + return notes.replace(/#(\d+)\b/g, `[#$1](${repositoryUrl}/pull/$1)`); +} + function formatReleaseNotes(notes: string) { return notes .replace( @@ -193,9 +199,15 @@ export const buildDiscordReleaseAnnouncement = ( options.target === "prerelease" ? "A new T3 Code prerelease is available for nightly testers." : `[View full release notes on GitHub](${options.releaseUrl.href})`; + // Generated summaries fold the full list under
    , which Discord cannot render. + const [summary = "", folded] = (options.releaseNotes ?? "").split(/^
    /m); const notes = options.target === "prerelease" - ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? "")) + ? suppressMentions( + folded === undefined + ? formatReleaseNotes(summary.trim()) + : `${linkPullRequestNumbers(summary.trim(), options.releaseUrl)}\n\n[Full changelog](${options.releaseUrl.href})`, + ) : ""; const description = notes ? `${intro}\n\n${notes}` : intro; // Separate embeds avoid URL deduplication; reserve title and footer space within Discord's 6000-character limit. diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index 4973cf10a423..f8828210862e 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -109,11 +109,36 @@ const isComplete = (dir) => { function publish(staging, dir) { try { fs.renameSync(staging, dir); + return; } catch (error) { if (isComplete(dir)) return; if (!fs.existsSync(dir)) throw error; - fs.rmSync(dir, { recursive: true, force: true }); - fs.renameSync(staging, dir); + } + const lock = `${dir}.lock`; + try { + fs.mkdirSync(lock); + } catch (error) { + if (error.code !== "EEXIST") throw error; + throw new Error( + `another t3 is installing ${version}. If none is running, delete ${lock} and try again.`, + { cause: error }, + ); + } + const stale = `${staging}.stale`; + try { + // A peer may have published between our marker check and taking the lock. + if (isComplete(dir)) return; + // Move it aside rather than deleting in place: once the folder is empty, a + // peer's first rename can land in it. + fs.renameSync(dir, stale); + try { + fs.renameSync(staging, dir); + } catch (error) { + if (!isComplete(dir)) throw error; + } + } finally { + fs.rmSync(lock, { recursive: true, force: true }); + fs.rmSync(stale, { recursive: true, force: true }); } } diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index c5344bdeb43f..1318aa3c0c6d 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -97,7 +97,12 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } const contentDir = path.join(scratch, root); const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3"); - for (const required of [executable, path.join(contentDir, "client/index.html")]) { + const bundledLibatomic = path.join(contentDir, "lib/libatomic.so.1"); + for (const required of [ + executable, + path.join(contentDir, "client/index.html"), + ...(platform === "linux" ? [bundledLibatomic, `${bundledLibatomic}.LICENSE`] : []), + ]) { if (!(yield* fs.exists(required))) { return yield* new CliArchiveSmokeError({ step: "checking the archive layout", @@ -106,6 +111,22 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { } } + // The runner has libatomic installed; fresh Ubuntu servers do not. + if (platform === "linux") { + const ldd = yield* spawner.string(ChildProcess.make("ldd", [executable])); + // Older glibc prints the path as given, newer prints it resolved; compare both resolved. + const loaded = /libatomic\.so\.1 => (\/.*) \(0x[0-9a-f]+\)/.exec(ldd)?.[1]; + if ( + loaded === undefined || + (yield* fs.realPath(loaded)) !== (yield* fs.realPath(bundledLibatomic)) + ) { + return yield* new CliArchiveSmokeError({ + step: "checking that t3 loads the bundled libatomic", + detail: ldd, + }); + } + } + const version = yield* runExecutable(executable, ["--version"], contentDir); if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) { return yield* new CliArchiveSmokeError({ diff --git a/vite.config.ts b/vite.config.ts index 909becac3177..468837c25568 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,7 +149,11 @@ export default defineConfig({ : {}), }, staged: { - "*": "vp fmt --no-error-on-unmatched-pattern", + // In order, so lint reads the formatted file. CI lint failures cost a full round trip. + "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"], + // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave + // a file, export, or dependency unused. + "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check", }, fmt: { ignorePatterns: [