diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 353a1f41a2616..1c4ae817060a9 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 9396d8e3e7a63..41efb7b69a5a2 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 1266d4936af1e..a4760193d5bbb 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-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 5849b71ddbb8a..6ef032450e04f 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: 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 0000000000000..45e4c9f2ad42d --- /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 ba40c4fb89cba..5a16a9b4c170f 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 afe32c73ed94e..4f0122d3094c7 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 cd27babf3f81f..493f7fc0be715 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. diff --git a/README.md b/README.md index 36f558a79dc60..6f6dab985e154 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 3030a3e926f29..f302ac049f6e6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.76", + "version": "0.0.77", "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 3cdfdb6cfa383..8566613dd6069 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 2ae070d638263..f53895a60a570 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 feedbbd580074..9404b0ac3087c 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 dd38acbc3894e..ebd7d71b25726 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 45d1771f26f07..aec3a2d5b45c0 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 d84b6f360c89e..27af3f2cfd8ae 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 b2212609030d1..ca85ee8b0c417 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 a3e1e711cb2d3..965b7db5d6d40 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -261,11 +261,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 22af4a829e049..b1f2b7ca0df92 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 2ea7efdb88ee0..e467c370d0f5b 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 5767057b4a35d..a0624ced669ea 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 88830c208ed61..2b77d910b5104 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -67,6 +67,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 9d12ed5a07fe6..4804823069024 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 25c37ecefc051..6e0fdbd38050f 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 253a015d8ec9e..1f5321148b77b 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 cb606d8fbd253..560e2e78d4bb1 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 d2f24143fc209..c6bcd46b8c206 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 aeda73f2bfad3..3a0b41cd9e369 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 028e42fa6e3b7..d88838439dba7 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 69d710ff7b269..2704563cfc0b8 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 d79a374a668a5..b07735ceb7a29 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 1d8c21a42dfe5..d8d16b7dbfd62 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 36dc282e89b47..e37dd8fb7a894 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 a0eedfbf0df0e..0000000000000 --- 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 821a3dac22aff..0000000000000 --- 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 11b51331eef34..48c0c3e9abc50 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 6e84cba1949cd..0000000000000 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 c75f7bf1b4656..0000000000000 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 289de93149abe..0000000000000 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 de39acbc40690..0000000000000 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 a53d52d6d02cb..0000000000000 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 82f96681c0762..0000000000000 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 4d09cda4a4c3f..0000000000000 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 e38f5538f84e9..0000000000000 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 6b4bf819e2d12..d1aea12f09bc9 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 2f3933d61239c..0000000000000 --- 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/src/features/files/fileTree.test.ts b/apps/mobile/src/features/files/fileTree.test.ts new file mode 100644 index 0000000000000..d978333d2732f --- /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 4e4b4b77171dd..22e8e9bd5bd8d 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/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 80391ef4060eb..bd3f04d9d6b4d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -41,7 +41,7 @@ import { pullRequestDiffInitialPosition, } from "./pullRequestDiffDebug"; import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles"; -import { isPullRequestTestFile } from "./pullRequestFileFilters"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; @@ -794,7 +794,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps ) : null} - {tab === "guide" ? : null} + {tab === "guide" ? ( + + ) : null} {tab === "activity" ? ( (filter.hideTestFiles); const hasTestFiles = result.parsed.kind === "files" && - result.parsed.files.some((file) => isPullRequestTestFile(file.path)); + result.parsed.files.some((file) => isTestChangedFile(file.path)); const jumpStarted = () => jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles); const scrollToFile = (fileId: string) => { diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index b5fb14b198ab4..6c675219dc943 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -44,6 +44,7 @@ import { scopedPullRequestListKey as pullRequestKey, type EnvironmentPullRequestListEntry, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; @@ -221,10 +222,14 @@ export function PullRequestsScreen( } const labelColors = knownFacets.environmentId === environmentId ? knownFacets.labelColors : facets.labelColors; - const projects = allProjects.filter( - (project) => - !project.isScratch && (environmentId === null || project.environmentId === environmentId), - ); + const projects = pullRequestProjectChoices(allProjects, { + environmentId, + controls, + environmentLabels: new Map( + environments.map((item) => [item.environmentId, item.environmentLabel]), + ), + servesPullRequests: (id) => configs.get(id)?.environment.capabilities.pullRequests === true, + }); const filters = usePullRequestListFilters({ environments: environments.map((item) => ({ id: String(item.environmentId), diff --git a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx index d198ea5b09e07..caa1e72938c87 100644 --- a/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx +++ b/apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx @@ -48,9 +48,15 @@ const attentionLabels = { accessibility: "An accessibility call", }; -export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { +export function PullRequestGuideContent({ + params, + visible = true, +}: { + readonly params: PullRequestParams; + readonly visible?: boolean; +}) { const navigation = useNavigation(); - const state = useReviewGuide(params); + const state = useReviewGuide(params, visible); const { guide, stale } = reviewGuideState(state.data); return ( diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx index 764cd5f881ac9..e797a12a57217 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.test.tsx @@ -3,18 +3,21 @@ import { type NavigationProp, type ParamListBase, } from "@react-navigation/native"; -import { act } from "react"; +import { AsyncResult } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ + enabled: true, + generate: vi.fn(), query: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, otherQuery: { - data: null as { run: { _tag: "Running" | "Completed" } } | null, + data: null as { run: { _tag: "Running" | "Completed" | "Failed" } | null } | null, isPending: false, refresh: vi.fn<() => void>(), }, @@ -33,7 +36,8 @@ vi.mock("@react-navigation/native", async () => { return { NavigationContext, useFocusEffect }; }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: (atom: unknown) => (atom === "settings" ? { reviewGuidesEnabled: true } : true), + useAtomValue: (atom: unknown) => + atom === "settings" ? { reviewGuidesEnabled: mocks.enabled } : true, })); vi.mock("../../../state/server", () => ({ serverEnvironment: { settingsValueAtom: () => "settings" }, @@ -48,7 +52,7 @@ vi.mock("../../../state/pull-requests", () => ({ vi.mock("../../../state/query", () => ({ useEnvironmentQuery: (number: number) => ({ ...(number === 1 ? mocks.query : mocks.otherQuery) }), })); -vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.generate })); vi.mock("../usePullRequestConnected", () => ({ usePullRequestConnected: () => true })); vi.mock("../usePullRequestDiff", () => ({ pullRequestTarget: (params: unknown) => params, @@ -78,6 +82,8 @@ function createNavigation() { function setup() { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mocks.enabled = true; + mocks.generate = vi.fn().mockResolvedValue(AsyncResult.success(undefined)); mocks.query = { data: null, isPending: false, refresh: vi.fn() }; mocks.otherQuery = { data: null, isPending: false, refresh: vi.fn() }; const root = createRoot(document.createElement("div")); @@ -88,20 +94,35 @@ function setup() { repository: "repo", number: 1, }; - function Probe({ number, environmentId }: { number: number; environmentId: string }) { - useReviewGuide({ ...params, number, environmentId }); + let current: ReturnType; + function Probe({ + number, + environmentId, + visible, + }: { + number: number; + environmentId: string; + visible?: boolean; + }) { + const state = useReviewGuide({ ...params, number, environmentId }, visible); + useLayoutEffect(() => { + current = state; + }); return null; } return { - render(number = 1, environmentId = params.environmentId) { + render(number = 1, environmentId = params.environmentId, visible?: boolean) { act(() => root.render( - + , ), ); }, + get current() { + return current; + }, focus: navigation.focus, [Symbol.dispose]() { act(() => root.unmount()); @@ -188,3 +209,94 @@ it("retires the old environment's polling on target change and clears polling on expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); + +it("suspends only manual refresh while hidden, including focus and target changes", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + { + using screen = setup(); + mocks.query.data = { run: { _tag: "Running" } }; + mocks.otherQuery.data = { run: { _tag: "Running" } }; + screen.render(); + const cached = screen.current.data; + screen.render(1, "environment", false); + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + expect(screen.current.data).toBe(cached); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + screen.render(1, "environment", false); + screen.focus(false); + screen.focus(true); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + + screen.render(2, "other-environment", false); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(false); + screen.render(2, "other-environment", true); + expect(mocks.otherQuery.refresh).not.toHaveBeenCalled(); + screen.focus(true); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.otherQuery.refresh).toHaveBeenCalledTimes(2); + expect(mocks.query.refresh).toHaveBeenCalledTimes(3); + } + expect(vi.getTimerCount()).toBe(0); +}); + +it("keeps disabled, completed and failed guides idle and refreshes once on reveal", () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + mocks.enabled = false; + screen.render(); + act(() => vi.advanceTimersByTime(5_000)); + expect(mocks.query.refresh).not.toHaveBeenCalled(); + mocks.enabled = true; + mocks.query.data = { run: null }; + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(1, "environment", false); + mocks.query.data = { run: { _tag: "Failed" } }; + screen.render(1, "environment", false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(1); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + act(() => vi.advanceTimersByTime(10_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); + +it("leaves generation running when hidden and accepts its completion without a refresh", async () => { + vi.useFakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using screen = setup(); + const generated = Promise.withResolvers>>(); + mocks.generate.mockReturnValue(generated.promise); + screen.render(); + let writing: Promise; + act(() => { + writing = screen.current.write(); + }); + expect(screen.current.running).toBe(true); + screen.render(1, "environment", false); + const beforeHidden = mocks.query.refresh.mock.calls.length; + act(() => vi.advanceTimersByTime(60_000)); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + expect(mocks.generate).toHaveBeenCalledTimes(1); + expect(screen.current.running).toBe(true); + await act(async () => { + generated.resolve(AsyncResult.success(undefined)); + await writing; + }); + expect(screen.current.running).toBe(false); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden); + screen.render(); + expect(mocks.query.refresh).toHaveBeenCalledTimes(beforeHidden + 1); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts index 5dad083e76108..bbdb6caee00c3 100644 --- a/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts +++ b/apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts @@ -17,7 +17,7 @@ export function useReviewGuidesEnabled(params: PullRequestParams) { return settings?.reviewGuidesEnabled === true; } -export function useReviewGuide(params: PullRequestParams) { +export function useReviewGuide(params: PullRequestParams, visible = true) { const target = pullRequestTarget(params); const enabled = useReviewGuidesEnabled(params); const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); @@ -35,7 +35,7 @@ export function useReviewGuide(params: PullRequestParams) { const running = starting || query.data?.run?._tag === "Running"; const refresh = query.refresh; useFocusEffect(() => { - if (!enabled) return; + if (!enabled || !visible) return; refresh(); if (!running) return; const timer = setInterval(refresh, 5_000); diff --git a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts index 2d8e3a94fe4be..2e0424ddbbaed 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts @@ -1,12 +1,6 @@ -import { classifyChangedFile } from "@t3tools/shared/changedFileKind"; +import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; import type { ReviewParsedDiff } from "../review/reviewModel"; -/** Test support such as fixtures and snapshots is hidden along with the tests. */ -export function isPullRequestTestFile(path: string): boolean { - const kind = classifyChangedFile(path); - return kind === "tests" || kind === "mocks"; -} - export function filterPullRequestTestFiles( parsed: ReviewParsedDiff, hideTestFiles: boolean, @@ -14,7 +8,7 @@ export function filterPullRequestTestFiles( ): ReviewParsedDiff { if (!hideTestFiles || parsed.kind !== "files") return parsed; const files = parsed.files.filter( - (file) => file.path === includePath || !isPullRequestTestFile(file.path), + (file) => file.path === includePath || !isTestChangedFile(file.path), ); if (files.length === parsed.files.length) return parsed; return { diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index f7b1a7a1bf832..b6f47b6533ee2 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, + ProjectId, SourceControlProviderKind, type PullRequestListEntry, type PullRequestListResult, @@ -26,6 +27,7 @@ import { withListedPullRequests, withShownPullRequestStats, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; function entry(number: number, patch: Partial = {}): PullRequestListEntry { @@ -402,6 +404,53 @@ describe("pullRequestFilterChoices", () => { }); }); +describe("pullRequestProjectChoices", () => { + const laptop = EnvironmentId.make("env-laptop"); + const desktop = EnvironmentId.make("env-desktop"); + const project = (id: string, environmentId: EnvironmentId) => ({ + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: { canonicalKey: "github.com/pingdotgg/t3code" }, + }); + const projects = [project("main", laptop), project("worktree", laptop), project("main", desktop)]; + const choices = ( + servesPullRequests: (environmentId: EnvironmentId) => boolean, + scope: { projectId?: string; projectEnvironmentId?: string } = {}, + ) => + pullRequestProjectChoices(projects, { + environmentId: null, + controls: { ...DEFAULT_PR_LIST_CONTROLS, ...scope }, + environmentLabels: new Map([ + [laptop, "laptop"], + [desktop, "desktop"], + ]), + servesPullRequests, + }).map(({ id, environmentId, title }) => ({ id, environmentId, title })); + + it("lists each repository once per environment that serves pull requests", () => { + expect(choices(() => true)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code ยท desktop" }, + { id: "main", environmentId: laptop, title: "t3code ยท laptop" }, + ]); + expect(choices((environmentId) => environmentId === desktop)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code" }, + ]); + }); + + it("keeps the saved scope when its environment stops serving or has not loaded", () => { + const scope = { projectId: "worktree", projectEnvironmentId: laptop }; + expect(choices((environmentId) => environmentId === desktop, scope)).toEqual([ + { id: "main", environmentId: desktop, title: "t3code ยท desktop" }, + { id: "worktree", environmentId: laptop, title: "t3code ยท laptop" }, + ]); + expect(choices(() => false, scope)).toEqual([ + { id: "worktree", environmentId: laptop, title: "t3code" }, + ]); + }); +}); + describe("shown PR diff stats", () => { const cups = EnvironmentId.make("cups"); const stat = (number: number, additions: number, deletions: number) => ({ diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index fcb41ac8ec861..674d3b0e24fe3 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -1,3 +1,4 @@ +import { pullRequestFilterProjects } from "@t3tools/client-runtime/pull-request-project-filter"; import { parsePullRequestQuery } from "@t3tools/client-runtime/pull-request-query"; import { ProjectId, @@ -34,6 +35,34 @@ export function withoutScratchProjectScope( : controls; } +type FilterProject = Parameters[0][number]; + +export function pullRequestProjectChoices< + Project extends FilterProject & { readonly isScratch?: true }, +>( + projects: readonly Project[], + input: { + readonly environmentId: string | null; + readonly controls: PullRequestListControls; + readonly environmentLabels: ReadonlyMap; + readonly servesPullRequests: (environmentId: EnvironmentId) => boolean; + }, +) { + const isSelected = (project: Project) => + project.id === input.controls.projectId && + project.environmentId === input.controls.projectEnvironmentId; + return pullRequestFilterProjects( + projects.filter( + (project) => + !project.isScratch && + (input.environmentId === null || project.environmentId === input.environmentId) && + (input.servesPullRequests(project.environmentId) || isSelected(project)), + ), + input.environmentLabels, + projects.find(isSelected), + ); +} + export function pullRequestListKey( item: Pick, ) { diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index bc0e23b336582..1b2131c73e0c1 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,6 +1,9 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; -import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; +import { + checkpointFileTotals, + type ThreadCheckpointSummary, +} from "@t3tools/client-runtime/state/thread-checkpoints"; import type { ReviewDiffPreviewSource } from "@t3tools/contracts"; import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import * as Arr from "effect/Array"; @@ -107,7 +110,7 @@ function checkpointTitle(checkpoint: ThreadCheckpointSummary): string { } function checkpointSubtitle(checkpoint: ThreadCheckpointSummary): string { - const fileCount = checkpoint.files.length; + const fileCount = checkpointFileTotals(checkpoint).count; if (checkpoint.status !== "ready") { return `Diff ${checkpoint.status}`; } diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 012d00737d1df..842965dbf1575 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -12,7 +12,10 @@ import { isLearned, } from "@t3tools/client-runtime/connection"; 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 { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; import { useState } from "react"; @@ -163,8 +166,14 @@ export function EnvironmentRoutesSection({ ) : ( - Permissions not checked. Connect to this environment to view this sessionโ€™s - permissions. + {sessionPermissionsUnavailableMessage({ + connected: activeRouteId !== null, + failed: sessionResult._tag === "Failure", + rejected: + sessionResult._tag !== "Failure" && + !sessionResult.waiting && + session?.authenticated === false, + })} )} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 687b061fdf655..f34df3c012d21 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -8,6 +8,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { sessionPermissionsUnavailableMessage } from "@t3tools/shared/authScopeOptions"; import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import { AsyncResult } from "effect/reactivity"; @@ -208,11 +209,14 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ) : !allowed ? ( - {AsyncResult.isFailure(sessionResult) - ? "Could not verify your permissions. Reconnect to try again." - : session === null - ? "Checking permissionsโ€ฆ" - : "This connection does not have permission to manage the environment."} + {AsyncResult.isFailure(sessionResult) || session === null || !session.authenticated + ? sessionPermissionsUnavailableMessage({ + connected, + failed: AsyncResult.isFailure(sessionResult), + rejected: + !AsyncResult.isFailure(sessionResult) && session?.authenticated === false, + }) + : "This connection does not have permission to manage the environment."} ) : null} {error ? ( diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index 644c6d25ee3d4..afcf4ba30d9a1 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -44,7 +44,7 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number }) const theme = getMobileTerminalTheme(themeId, scheme); const lineHeight = Math.round(props.fontSize * 1.6); const lineStyle = { - fontFamily: "Menlo", + fontFamily: Platform.OS === "android" ? "monospace" : "Menlo", fontSize: props.fontSize, lineHeight, } as const; diff --git a/apps/mobile/src/features/terminal/terminalInput.test.ts b/apps/mobile/src/features/terminal/terminalInput.test.ts index 74240215af08a..17fabe588115c 100644 --- a/apps/mobile/src/features/terminal/terminalInput.test.ts +++ b/apps/mobile/src/features/terminal/terminalInput.test.ts @@ -12,6 +12,38 @@ const ESC = byte(0x1b); const CTRL_V = byte(0x16); describe("resolveModifiedTerminalInput", () => { + it.each(["A", "B", "C", "D"])("encodes ctrl+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "ctrl", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;5${arrow}` }); + }); + + it.each(["A", "B", "C", "D"])("encodes meta+arrow %s with the cursor modifier", (arrow) => { + expect( + resolveModifiedTerminalInput({ + data: `${ESC}[${arrow}`, + modifier: "meta", + hostPlatform: "linux", + }), + ).toEqual({ kind: "write", data: `${ESC}[1;3${arrow}` }); + }); + + it.each(["a", "", ESC, `${ESC}[H`, `${ESC}[1;5D`, `${ESC}[D\n`])( + "preserves non-arrow modifier handling for %j", + (data) => { + expect( + resolveModifiedTerminalInput({ data, modifier: "meta", hostPlatform: "linux" }), + ).toEqual({ + kind: "write", + data: `${ESC}${data}`, + }); + }, + ); + it("pastes on ctrl+v for windows, linux, and unknown hosts", () => { for (const hostPlatform of ["windows", "linux", "unknown"] as const) { expect(resolveModifiedTerminalInput({ data: "v", modifier: "ctrl", hostPlatform })).toEqual({ diff --git a/apps/mobile/src/features/terminal/terminalInput.ts b/apps/mobile/src/features/terminal/terminalInput.ts index 09886b41cb2bb..9c5e05dcc24f0 100644 --- a/apps/mobile/src/features/terminal/terminalInput.ts +++ b/apps/mobile/src/features/terminal/terminalInput.ts @@ -45,6 +45,14 @@ export function resolveModifiedTerminalInput(input: { return { kind: "paste" }; } + if ( + input.data.length === 3 && + input.data.startsWith("\u001b[") && + /[ABCD]/.test(input.data.charAt(2)) + ) { + return { kind: "write", data: `\u001b[1;${input.modifier === "ctrl" ? 5 : 3}${input.data[2]}` }; + } + return { kind: "write", data: input.modifier === "ctrl" ? applyCtrlModifier(input.data) : `\u001b${input.data}`, diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx index 509545b58c8bb..5c3d758f45513 100644 --- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx +++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx @@ -87,7 +87,11 @@ export function DraftThreadHeader(props: { }, onRunAction: async (input) => { if (!canWriteGit || !props.cwd) return null; - const result = await runAction({ ...input, actionId: uuidv4() }); + const result = await runAction({ + ...input, + projectId: props.project.id, + actionId: uuidv4(), + }); if (AsyncResult.isFailure(result)) { Dialog.alert("Git action failed", String(Cause.squash(result.cause))); return null; diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index d097416b2ef20..9ecbbb70f1d8e 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -26,9 +26,9 @@ import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, - type PendingUserInput, type PendingUserInputDraftAnswer, -} from "../../lib/threadActivity"; +} from "@t3tools/client-runtime/state/pending-user-input"; +import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a25725d1a1c73..3480f62e9f5d3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -158,10 +158,10 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import type { PendingApproval, PendingUserInput, - PendingUserInputDraftAnswer, ThreadFeedEntry, ThreadFeedLatestRun, } from "../../lib/threadActivity"; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx new file mode 100644 index 0000000000000..e611b95d3f683 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.occupancy.test.tsx @@ -0,0 +1,102 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const checkout = vi.hoisted(() => ({ + projectRoot: "", + worktreePath: null as string | null, + branches: [] as Array<{ name: string; worktreePath: string | null }>, +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "android" }, + View: ({ children }: { children: ReactNode }) =>
{children}
, + ScrollView: ({ children }: { children: ReactNode }) =>
{children}
, + Pressable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => ( + + ), + 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 4075a49e9774a..dcaf96818bfe0 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 b2159706abeee..14a9720cb4e96 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"; @@ -483,6 +485,7 @@ export function NewTaskFlowProvider( selectedProjectDraft.modelSelection ?? null, selectedProject?.id ?? null, ); + const modelSelectionNow = useNowMinuteMs(); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, projectSettings.settings.defaultModelSelection, @@ -501,12 +504,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 314dc1aa477b5..3d9463c79bd06 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 9eaf42dcee81b..d38cc026fdd62 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 39c5ac82362a4..5b0c1fd2f5284 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -62,6 +62,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, @@ -837,6 +839,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" }, @@ -844,8 +847,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( () => [ @@ -923,6 +941,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 }, diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index d8dde459792d1..7f560497da254 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 0000000000000..44e6e03fe0d9b --- /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/workspace/WorkspaceNativeTabs.ios.tsx b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx index 929660587d731..e93175677fc02 100644 --- a/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx @@ -18,7 +18,11 @@ import { NativeWorkspaceTabActivityContext, WorkspaceSlotOptionsContext, } from "../../native/v5-workspace-context"; -import { useCompactWorkspaceTabs, useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar"; +import { + useCompactWorkspaceTabs, + useFloatingWorkspaceTabs, + useSystemWorkspaceTabBar, +} from "./useSystemWorkspaceTabBar"; import { useControlTheme } from "../../components/ui/useControlTheme"; import { useDeviceType } from "../../native/native-layout-metrics"; import { WorkspaceSidebarTabs } from "./WorkspaceSidebarTabs"; @@ -80,7 +84,7 @@ export function WorkspaceNativeTabs(props: { const deviceType = useDeviceType(); const compactTabs = useCompactWorkspaceTabs(); const { layout, startNewTask } = useAdaptiveWorkspaceLayout(); - const compactSidebarTabs = deviceType === "phone" || !layout.usesSplitView; + const floatingTabs = useFloatingWorkspaceTabs(); const { width } = useWindowDimensions(); const { colors } = useControlTheme(); const selectedTab = workspace.state.selectedTab; @@ -158,17 +162,17 @@ export function WorkspaceNativeTabs(props: { bottom: 0, // UIKit can shrink the sidebar to 280pt independently of its preferred width. width: - deviceType === "pad" + deviceType === "pad" && (floatingTabs || layout.usesSplitView) ? Math.min(280, layout.listPaneWidth ?? width, width) : (layout.listPaneWidth ?? width), height: 128, }} > (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 fddeac277bcfe..18cd93b3fe723 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 5060f359ebcd3..96626a5caefab 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 fc085a7c50a0d..bd7af44b2fa07 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 ab9f26dd7d412..d7935d1debcab 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 361a743ac71b5..6670505ad3988 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 16f2de8f099b4..6c5da0db20991 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 acfdd803d4259..0b529e9947805 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 edc80c5007024..0ea64764e5da2 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) }, }; }); vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } })); @@ -224,6 +224,7 @@ beforeEach(() => { ]) { command.mockReset().mockResolvedValue(AsyncResult.success(undefined)); } + state.start.mockResolvedValue(AsyncResult.success({})); state.prepare.mockReset().mockImplementation(async ({ attachments }) => ({ status: "ready", attachments: [], @@ -427,7 +428,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; @@ -437,7 +439,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 901ffaa9e8291..d77ffe1942b6c 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 }, }; }); @@ -138,6 +143,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"; @@ -204,7 +210,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: {} }); }); @@ -330,26 +336,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 () => { @@ -359,7 +371,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([]); @@ -390,9 +406,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, @@ -412,7 +428,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]); }); @@ -447,7 +465,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(); @@ -458,7 +478,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]); @@ -479,7 +501,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 5ba633de634a7..4a20db3b9e965 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -250,6 +250,7 @@ function isQueuedMessagePayloadCurrent( export async function completeQueuedMessageDelivery( queuedMessage: QueuedThreadMessage, deliveryRevision: number, + sequence: number | undefined, ): Promise<"removed" | "edited" | "failed"> { clearThreadComposerError( scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), @@ -266,7 +267,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, @@ -616,6 +619,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, @@ -896,7 +905,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); } @@ -1052,7 +1065,11 @@ export function createThreadOutboxDelivery(): { return restoreQueuedMessage(persistedMessage, failure.message); } recordPendingThreadCreationOutcome({ kind: "delivered", message: persistedMessage }); - 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 f869719601750..d4bbc6bfe5bc0 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 bfbfce38acbd5..5625f27fbce17 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -115,6 +115,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; @@ -232,6 +233,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 @@ -259,6 +265,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 6a92a025778d9..9ddb84efbc832 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 22db0e643db9b..68a622c541fe9 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 315ebfc95a016..12f379e6206d9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.76", + "version": "0.0.77", "license": "MIT", "repository": { "type": "git", @@ -58,6 +58,7 @@ "@t3tools/source-control-github": "workspace:*", "@t3tools/source-control-gitlab": "workspace:*", "character-entities": "2.0.2", + "diff": "8.0.3", "effect": "catalog:", "jose": "catalog:", "node-pty": "^1.2.0-beta.15", diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 39a6e651b84dd..11cb490901a52 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 0ca85e6ea146e..8eb7894627230 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 8558a6664c8a6..bd5d3b569958f 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 3fbe5a0061c0e..7d73e65925228 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 3dc35aca981b9..29588040217ae 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/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 561633d3524b2..4e940cd36cc51 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 4999fef9c49b7..0709f371684fb 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 78fe75e594726..e8106f77f6889 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, @@ -178,7 +179,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( @@ -200,6 +201,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 51226b4a199d5..bd93db99c7b0c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -201,6 +201,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 1a2d6d407e57c..6748f39b430b8 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 dc89a5da81140..29c0c5100a190 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 0aeb7d6a3b5ab..06b949e3013ab 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 0000000000000..c799d3c2a5e10 --- /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 91232cb310871..18ee7bed6d5a1 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 560a711892bc7..50d72225de021 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 40a7ab7113839..723f5254e7a2c 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"; @@ -121,6 +128,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 a5c8aa532ff3e..3d02f9c921fce 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 84877d41a70c9..1e3157adfff59 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 e07437ef93d99..f7e74dd07cfe2 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 PseudoCode 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 18a5323cd590c..efc37266be8b0 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 8865ce7b3d11f..9479f3f4a8194 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 86d445d1a52c4..f90bef39fcf03 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 2f488dcca66db..a415152ebc38d 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 PseudoCode 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 PseudoCode 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 PseudoCode 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/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index 41e1de4304692..90f46a4b66d33 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 002d8cb3e7d28..6b734b6b9b484 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/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 5f821f5bf0217..b2ef41f148a14 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -20,6 +20,9 @@ import { type DeviceEasAccount, DeviceEasAccountError, type DeviceEasAccountInput, + type DeviceSimlockIdentity, + DeviceSimlockIdentityError, + type DeviceSimlockIdentityInput, type DeviceHostConfig, type EasDeviceHostConfig, type SimlockDeviceHostConfig, @@ -69,6 +72,7 @@ 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"; @@ -142,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. @@ -1012,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); @@ -1379,6 +1394,14 @@ export const make = Effect.gen(function* () { ); 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") { @@ -1478,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/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index f67f7bc8d76c6..1b670a06cd078 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -1,6 +1,7 @@ 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"; @@ -62,14 +63,16 @@ const fakePool = ( 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}`); - expect(request.headers.authorization).toBe("Bearer agent-token"); const json = (body: unknown, status = 200) => HttpClientResponse.fromWeb(request, Response.json(body, { status })); - if (url.pathname === "/v1/catalog") return json(CATALOG); + 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); @@ -134,6 +137,9 @@ const fakePool = ( endLeases: () => { renewStatus = 404; }, + setCatalog: (next: unknown) => { + catalog = next; + }, }; }); @@ -257,6 +263,84 @@ describe("Simlock device host", () => { }).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 }); diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index c2c25f0b7de21..75476f0333ce3 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -10,8 +10,10 @@ import { 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"; @@ -22,7 +24,7 @@ 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, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as HostProcess from "@t3tools/shared/HostProcess"; import type { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; @@ -83,6 +85,85 @@ const parseCatalogId = (id: string) => { 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 }, @@ -102,6 +183,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( 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(); @@ -114,6 +196,8 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( 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; @@ -139,7 +223,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( HttpClientRequest.bodyJsonUnsafe(body ?? {}), ) : HttpClientRequest.delete(`${gateway}${route}`) - ).pipe(HttpClientRequest.bearerToken(config.agentToken)), + ).pipe(HttpClientRequest.bearerToken(agentToken)), ) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), @@ -197,23 +281,20 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( return result.stdout; }); - const loadCatalog = Effect.gen(function* () { + const fetchCatalog = Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; if (catalog && now - catalog.at < CATALOG_TTL_MS) return catalog.devices; - if (!config.agentToken) { + 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( - `Could not reach the Simlock gateway at ${gateway}, or it refused the agent token.`, - cause, - ), + unavailable(describeGatewayFailure(cause, gateway, config.label), cause), ), ); - const devices = decoded.platforms.flatMap((entry) => { - if (entry.platform !== "ios" && entry.platform !== "android") return []; + 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]; @@ -232,10 +313,41 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( ]; }); }); - catalog = { at: now, devices }; + 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); @@ -319,7 +431,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( const configPath = path.join(dir, "agent-device.json"); yield* writeAgentDeviceConfig(configPath, { baseUrl: agentDeviceUrl, - token: config.agentToken, + token: agentToken, stateDir: dir, }).pipe( Effect.provide(services), @@ -445,7 +557,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( onSome: (lease) => Effect.succeed({ baseUrl: agentDeviceUrl, - token: config.agentToken, + token: agentToken, agentDeviceId: null, session: lease.session, stateDir: lease.dir, @@ -476,7 +588,7 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( if (!lease) return null; return { url: `${gateway}/v1/leases/${encodeURIComponent(lease.leaseId)}/${stream ? "stream" : "input"}`, - headers: { authorization: `Bearer ${config.agentToken}` }, + headers: { authorization: `Bearer ${agentToken}` }, protocols: [], }; }); @@ -515,19 +627,25 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( yield* Effect.addFinalizer(() => stopAll); const platformAvailability = (platform: DevicePlatform) => - Effect.succeed({ platform, available: true }); + loadCatalog.pipe( + Effect.ignore, + Effect.map( + (): DevicePlatformAvailability => + platforms.find((candidate) => candidate.platform === platform) ?? { + platform, + available: false, + }, + ), + ); - const summary: Effect.Effect = Effect.succeed({ + const summary: Effect.Effect = Effect.sync(() => ({ id: hostId, label: config.label, kind: "simlock" as const, - platforms: [ - { platform: "ios" as const, available: true }, - { platform: "android" as const, available: true }, - ], + platforms, hubInstalled: true, agentDeviceInstalled: true, - }); + })); return DeviceHost.DeviceHost.of({ id: hostId, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b6246d2073a38..bd203f4b971b9 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* () { threadReadState: 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 68eb815aeff17..676c8e4db647c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2600,7 +2600,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); @@ -2687,6 +2725,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); @@ -3907,7 +3946,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* () { @@ -3957,7 +3996,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", @@ -3994,9 +4033,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 }, @@ -4009,15 +4048,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 c5cb157a0e03f..292207e90d16c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -672,19 +672,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 7bb098da87862..fdc00849af2d0 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -377,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 31acd7bdb60bf..1ef25744c8c55 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 0346068bc546f..347bdc11ece5b 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/http.test.ts b/apps/server/src/http.test.ts index 0319bbb55c03d..5f7df20c738c8 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 8f9737a3efc18..e792a233c6f06 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/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index dba3bf7c08ee4..ec8c9434bec43 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -182,6 +182,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/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 0d7ca3e1c9d98..03b97c32af6db 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -1,8 +1,11 @@ +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, ProjectId, ProviderInstanceId, + ProviderDriverKind, + type ServerProvider, type Project as ProjectRecord, type ModelSelection, type RuntimeMode, @@ -37,6 +40,8 @@ import { ProjectToolkit } from "./tools.ts"; 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; @@ -54,7 +59,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 }), @@ -473,3 +482,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 79f18acbc2da5..014b109448f34 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -18,6 +18,9 @@ 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 { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -180,7 +183,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({ diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 38931596868b4..3ea5a8ea9e48f 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -23,6 +23,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"; @@ -166,6 +167,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + ProviderRegistry.ProviderRegistry, DocumentText.DocumentText, ], }) diff --git a/apps/server/src/observability/HeapAttribution.test.ts b/apps/server/src/observability/HeapAttribution.test.ts new file mode 100644 index 0000000000000..b355ba5c35e1f --- /dev/null +++ b/apps/server/src/observability/HeapAttribution.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; + +import * as HeapAttribution from "./HeapAttribution.ts"; + +const MB = 1024 * 1024; + +it.effect("names the span that grew the heap in a spike, not an older open or ended one", () => + Effect.gen(function* () { + const ended: Array = []; + const inner = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + ended.push(span); + }; + return span; + }, + }); + let heap = 300 * MB; + const attribution = yield* HeapAttribution.make(inner, () => heap); + yield* attribution.run; + // The sampler's baseline: heap before the spans allocate. + yield* TestClock.adjust("1 second"); + const open = (name: string) => + attribution.tracer.span({ + name, + parent: Option.none(), + annotations: Context.empty(), + links: [], + startTime: 0n, + kind: "internal", + root: false, + sampled: true, + }); + + const subscription = open("ws.rpc.orchestration.subscribeShell"); + const old = open("ProjectionStore.getSnapshot"); + // Slow drift never spikes, but leaves the long-lived span far above its start. + for (let step = 0; step < 5; step++) { + heap += 80 * MB; + yield* TestClock.adjust("11 seconds"); + if (step === 1) old.end(1n, undefined as never); + } + const small = open("ProjectionStore.getThreadShell"); + const big = open("ProjectionStore.getThreadProjection"); + heap += 2 * MB; + small.end(1n, undefined as never); + heap += 198 * MB; + yield* TestClock.adjust("2 seconds"); + + const spike = ended.find((span) => span.name === "server.heap.spike"); + assert.isDefined(spike); + assert.deepEqual(spike!.attributes.get("suspects"), [ + "ProjectionStore.getThreadProjection +200 MB", + ]); + big.end(2n, undefined as never); + subscription.end(3n, undefined as never); + assert.equal(big.attributes.get("memory.heap_growth_bytes"), 200 * MB); + assert.isUndefined(small.attributes.get("memory.heap_growth_bytes")); + assert.equal(old.attributes.get("memory.heap_growth_bytes"), 160 * MB); + }), +); diff --git a/apps/server/src/observability/HeapAttribution.ts b/apps/server/src/observability/HeapAttribution.ts new file mode 100644 index 0000000000000..4b18efd0fb911 --- /dev/null +++ b/apps/server/src/observability/HeapAttribution.ts @@ -0,0 +1,108 @@ +// @effect-diagnostics nodeBuiltinImport:off - only node:v8 exposes a heap reading cheap enough to take per span. +import * as NodeV8 from "node:v8"; + +import * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; +import * as Tracer from "effect/Tracer"; + +const MB = 1024 * 1024; +// Smaller growth is noise from concurrent fibers; an attribute on every span would bloat exports. +const SPAN_GROWTH_ATTRIBUTE_BYTES = 8 * MB; +const SPIKE_GROWTH_BYTES = 128 * MB; +const SPIKE_WINDOW_SAMPLES = 10; +const SPIKE_COOLDOWN_SAMPLES = 60; +const RECENT_SPANS = 32; +const REPORTED_SPANS = 8; + +const readHeapUsed = () => NodeV8.getHeapStatistics().used_heap_size; + +interface Growth { + readonly name: string; + readonly growthBytes: number; +} + +/** + * Heap growth is approximate: it includes other fibers' allocations and GC, so it names + * suspects, not culprits. A spike lists what was open or just grew. + */ +export const make = (tracer: Tracer.Tracer, heapUsed: () => number = readHeapUsed) => + Effect.sync(() => { + const open = new Map< + Tracer.Span, + { readonly name: string; readonly startHeap: number; readonly startTick: number } + >(); + const recent: Array = []; + const samples: Array = []; + let cooldown = 0; + let tick = 0; + + const wrapped = Tracer.make({ + ...(tracer.context === undefined ? {} : { context: tracer.context }), + span(options) { + const span = tracer.span(options); + const startHeap = heapUsed(); + open.set(span, { name: options.name, startHeap, startTick: tick }); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + open.delete(span); + const growthBytes = heapUsed() - startHeap; + if (growthBytes >= SPAN_GROWTH_ATTRIBUTE_BYTES) { + span.attribute("memory.heap_growth_bytes", growthBytes); + recent.push({ name: options.name, growthBytes, endTick: tick }); + if (recent.length > RECENT_SPANS) recent.shift(); + } + end(endTime, exit); + }; + return span; + }, + }); + + // Untraced: it runs every second and only emits a span when the heap spikes. + const sample = Effect.fnUntraced(function* () { + const heap = heapUsed(); + tick++; + samples.push(heap); + if (samples.length > SPIKE_WINDOW_SAMPLES) samples.shift(); + if (cooldown > 0) { + cooldown--; + return; + } + const growthBytes = heap - Math.min(...samples); + if (growthBytes < SPIKE_GROWTH_BYTES) return; + cooldown = SPIKE_COOLDOWN_SAMPLES; + const suspects = [ + // Spans open since before the window, such as subscriptions, would claim all of its growth. + ...[...open.values()] + .filter(({ startTick }) => tick - startTick <= samples.length) + .map(({ name, startHeap }) => ({ name, growthBytes: heap - startHeap })), + ...recent.filter(({ endTick }) => tick - endTick <= samples.length), + ] + .filter((suspect) => suspect.growthBytes >= SPAN_GROWTH_ATTRIBUTE_BYTES) + .toSorted((left, right) => right.growthBytes - left.growthBytes) + .slice(0, REPORTED_SPANS) + .map(({ name, growthBytes }) => `${name} +${Math.round(growthBytes / MB)} MB`); + recent.length = 0; + // A span, not a log: this fiber starts before the server logger is provided. + yield* Effect.void.pipe( + Effect.withSpan("server.heap.spike", { + root: true, + level: "Warn", + attributes: { + heapUsedMb: Math.round(heap / MB), + growthMb: Math.round(growthBytes / MB), + suspects, + }, + }), + ); + }); + + const run: Effect.Effect = sample().pipe( + Effect.andThen(Effect.sleep("1 second")), + Effect.forever, + Effect.withTracer(wrapped), + Effect.forkScoped, + Effect.asVoid, + ); + + return { tracer: wrapped, run }; + }); diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 343851e3d1d11..166d8ac194a8c 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts"; import * as ServerLogger from "../serverLogger.ts"; import * as BrowserTraceCollector from "./BrowserTraceCollector.ts"; +import * as HeapAttribution from "./HeapAttribution.ts"; export const layer = Layer.unwrap( Effect.gen(function* () { @@ -71,8 +72,11 @@ export const layer = Layer.unwrap( ...(delegate ? { delegate } : {}), }); + const heapAttribution = yield* HeapAttribution.make(tracer); + yield* heapAttribution.run; + return Layer.mergeAll( - Layer.succeed(Tracer.Tracer, tracer), + Layer.succeed(Tracer.Tracer, heapAttribution.tracer), BrowserTraceCollector.layer(sink), ); }), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 0ba4034b73902..154d36cb0505b 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/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fab0020b7e03e..e086f8e2d1a7c 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 4876465a960d3..0f5670f1d2de0 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 2c41e007edf65..9ccaca83b542f 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 a3eeb6cb66e83..79cf9fc3ea489 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -235,6 +235,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"], @@ -253,6 +268,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: { @@ -932,8 +969,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)'" }; @@ -942,6 +982,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( @@ -954,12 +995,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"); @@ -8827,6 +8872,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 3e25dba27f39b..9ce422e206148 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -108,6 +108,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"; @@ -136,7 +137,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, @@ -1475,6 +1479,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, @@ -1608,6 +1614,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 }; @@ -2984,6 +2991,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) { @@ -5518,7 +5563,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" ? { @@ -9588,7 +9635,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 fa260eeca5fbb..24edb34ca5395 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, @@ -569,6 +570,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -667,7 +669,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 }), + }, + }, }, }, }, @@ -5337,6 +5346,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"; @@ -6419,6 +6495,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 d52f5af86d87d..2ee3c39c15ac3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -2725,9 +2725,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, @@ -2735,6 +2732,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({ @@ -2811,9 +2811,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, @@ -3748,7 +3752,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) => @@ -3802,7 +3806,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 1bf294641a01c..cf982c9699f77 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 302bb12f797cb..869851790177a 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 c36dfc365af60..61515a053ac36 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 f445e2901a114..65f1cb3a99182 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": @@ -3226,8 +3250,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, @@ -3351,8 +3381,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 && @@ -4005,6 +4046,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, @@ -12509,6 +12600,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 @@ -13368,6 +13462,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 2af87ecf4feb7..1dc7c912e7765 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), @@ -4796,6 +4892,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, ); @@ -6402,6 +6499,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; }); @@ -6576,10 +6674,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 7680805796f42..f04bf9b8a81d6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -394,6 +394,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; @@ -451,6 +460,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, @@ -5969,7 +5985,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 `; @@ -5982,14 +5998,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 `; @@ -6004,7 +6020,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 @@ -6021,7 +6037,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 = ( @@ -6037,7 +6053,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`` @@ -6046,7 +6062,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') ` @@ -6643,6 +6659,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, ) { @@ -6797,6 +6905,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 d48e269866a50..1998b38f41020 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 01f474b8051aa..ec9d35d65d846 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -37,6 +37,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"; @@ -349,6 +350,7 @@ export const layer: Layer.Layer< | IdAllocator.IdAllocatorV2 | ProjectionStore.ProjectionStoreV2 | ThreadCommandExecutor.ThreadCommandExecutor + | ProviderInstanceRegistry.ProviderInstanceRegistry > = Layer.effect( ProviderEventIngestorV2, Effect.gen(function* () { @@ -359,6 +361,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 priceTokens = yield* ProviderUsagePricing; const usageLock = yield* Semaphore.make(1); @@ -940,6 +944,23 @@ export const layer: Layer.Layer< input.event.sessionCost !== undefined) ? usageLock.withPermits(1)(pendingEffect) : pendingEffect, + 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 0d8676ed323b2..60deb34d7272a 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"; @@ -533,6 +534,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 a39f6b7f036a6..1d79e87eb9b91 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 3ff9d08ce8952..e613e5345ffeb 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1311,6 +1311,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 17e04d9864e18..f5cd3cf5ea99d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -92,6 +92,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 d8ac4769e140f..1015e44694c81 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 d3506649f6a62..95abcbaf5c1a8 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 88fc87d3bc092..ea1669f4b7ddc 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -1380,6 +1380,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 83a4e88b725f7..489f8b0002a70 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()( @@ -514,12 +515,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) { @@ -945,6 +943,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`); @@ -991,6 +990,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", ); @@ -1057,6 +1057,7 @@ const make = Effect.gen(function* () { input.initialMessage === undefined ? Option.isSome(launchReceipt) || claimed.replayed === true : messageWasAlreadyAccepted, + ...(messageSequence === undefined ? {} : { sequence: messageSequence }), }; }); }, @@ -1066,12 +1067,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); @@ -1093,30 +1096,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 @@ -1132,7 +1138,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 cfbbd4a68a51d..7b79b21e88153 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -394,10 +394,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, @@ -1333,6 +1338,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 5627a6dc37aa9..5f380c7e0ace7 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 7b3c09ba15b8a..0db9feac8587e 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/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 012e3d2f234ca..0a0996c0ede0c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -842,6 +842,29 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("stops settling the rest of a sweep once auto-settlement is turned off", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + // Sweeps settle 8 threads at a time, so the ninth starts after a dispatch turned it off. + const threads = Array.from({ length: 9 }, (_, index) => makeThread(`idle-${index}`)); + let disable: Effect.Effect = Effect.void; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot(threads), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 }, + onDispatch: () => disable, + }); + disable = fixture.updateSettings({ sidebarAutoSettleAfterDays: null }); + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + const settled = (yield* Ref.get(fixture.commands)).map((command) => command.threadId); + expect(settled).not.toContain(threads[8]!.id); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 509dcd0307dc2..6a3d4fffb0399 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -321,8 +321,10 @@ export const make = Effect.gen(function* () { // Merge events resweep all candidates because unlinked threads settle through branch lookup. const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); - const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")( + // Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span. + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + // thread.auto-settle does not recheck settings, so read them per decision; reads are cached. const currentSettings = resolveProjectSettings( yield* settingsService.getSettings, thread.projectId, @@ -414,7 +416,8 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); - const wouldSettle = Effect.fn("ThreadSettlementServiceV2.wouldSettle")(function* ( + // wouldSettle and pullRequestFor run per lookup group; the git and pull request reads they make carry the spans. + const wouldSettle = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, pullRequest: SettlementPullRequest, ) { @@ -435,7 +438,7 @@ export const make = Effect.gen(function* () { }); }); - const pullRequestFor = Effect.fn("ThreadSettlementServiceV2.pullRequestFor")(function* ( + const pullRequestFor = Effect.fnUntraced(function* ( group: ReadonlyArray<(typeof lookupCandidates)[number]>, ) { const thread = group[0]!; diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index 3c4baab80ef07..80ee5c32d6152 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 d449aeb933dbf..0d63d832f0ca9 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 6b66f1b91f204..2181b7feec5fa 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 61ff54d0c794f..c2c67eb04d9dc 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 d427405999d16..d581528db5bc1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -37,6 +37,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"; @@ -76,6 +77,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"; @@ -5809,6 +5811,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* () { @@ -5904,6 +6098,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) => { @@ -6006,6 +6282,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 6bd33ebd26187..1b54627595372 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"; @@ -289,6 +290,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( @@ -362,6 +373,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 2c93f22b647ef..2fdd6cb2c9f75 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"; @@ -321,6 +322,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 4e2d572bd96b4..8754bdbd2edcd 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/DatabaseStorage.ts b/apps/server/src/persistence/DatabaseStorage.ts index 4907bc20f15b0..bbb4dd1399efd 100644 --- a/apps/server/src/persistence/DatabaseStorage.ts +++ b/apps/server/src/persistence/DatabaseStorage.ts @@ -49,17 +49,30 @@ const make = Effect.gen(function* () { const doctorTargetsThisDatabase = path.resolve(config.dbPath) === path.resolve(config.baseDir, "userdata", path.basename(config.dbPath)); + // Only VACUUM changes these, and doctor refuses to VACUUM while a server has the database open. + let layout: { readonly pageSize: number; readonly incrementalVacuum: boolean } | undefined; + const readLayout = Effect.suspend(() => + layout !== undefined + ? Effect.succeed(layout) + : Effect.gen(function* () { + const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; + const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; + layout = { + pageSize: size[0]?.page_size ?? 0, + incrementalVacuum: mode[0]?.auto_vacuum === 2, + }; + return layout; + }), + ); const publishStatus = Effect.gen(function* () { if (!doctorTargetsThisDatabase) return; + const { pageSize, incrementalVacuum } = yield* readLayout; const pages = yield* sql<{ readonly page_count: number }>`PRAGMA page_count`; const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; - const size = yield* sql<{ readonly page_size: number }>`PRAGMA page_size`; - const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; - const pageSize = size[0]?.page_size ?? 0; const next: DatabaseStorageStatus = { databaseBytes: (pages[0]?.page_count ?? 0) * pageSize, reclaimableBytes: (free[0]?.freelist_count ?? 0) * pageSize, - incrementalVacuumEnabled: mode[0]?.auto_vacuum === 2, + incrementalVacuumEnabled: incrementalVacuum, compactCommand, }; // Every set reaches each open Storage page; most passes change nothing. @@ -71,8 +84,7 @@ const make = Effect.gen(function* () { ); const reclaim = Effect.gen(function* () { - const mode = yield* sql<{ readonly auto_vacuum: number }>`PRAGMA auto_vacuum`; - if (mode[0]?.auto_vacuum !== 2) return; + if (!(yield* readLayout).incrementalVacuum) return; // The driver steps once per statement; incremental_vacuum frees one page per step. Yield between pages. for (let page = 0; page < RECLAIM_PAGES_PER_PASS; page++) { const free = yield* sql<{ readonly freelist_count: number }>`PRAGMA freelist_count`; diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f5..54e4a711aa8a1 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 a01e5910b883a..a854080505126 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"; export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -151,6 +152,7 @@ export 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 0000000000000..d9759bca6d82a --- /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 45b28834a4ad7..940d626514336 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 48c1ab8fc8913..2f21ea0a8a942 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 0000000000000..046039a703410 --- /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 561cd72ea9f71..ec8ded1cab5ac 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 755907b5076f9..4f6d8d969c3ec 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 91e40578091a7..c08062c09d1ad 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 0cb1665d4f3d4..a5c241229a377 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 d13b495ef5c20..ea5539aad5e02 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 1e816f31b77d5..3262c555bcf8c 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 b8c0911a520d2..6a2eb145162e2 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 18004510baa45..3ba57db6d7e5a 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 0000000000000..671a480a95832 --- /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 94827d6294640..a072ef597b95e 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -125,7 +125,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 3d590abda0bdb..902e6508ca25d 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 ade033551f655..1f852ad8bcc87 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 3f8366218a897..9c932a0cfef55 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 fd5725c6cd798..f1f39e104b44c 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 86f6d48f203a8..e814b795290aa 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -309,6 +309,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 2897cc0b84282..860280aa91e89 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 56c2ff19bebdf..bc68e42b76a2a 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 90e37cc66712b..0aacca303e6a6 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 843e918f54076..bb2db215eec85 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,7 +37,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -56,20 +55,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 bd48ee767ee46..8e0eed1371c5b 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 ac64c9da2a469..a57fc08f9b7af 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 4efbc28a80ce2..c6079234d916c 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 192c8b43ffb14..909de58cb7557 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 7e2f24a31d3b9..a91d45164caaf 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 12232e52db252..9b11c91a4524e 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5144, "codexAdditionalContextBytes": 6880, - "mcpToolListJsonBytes": 48004 + "mcpToolListJsonBytes": 48104 } diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index f00185865b05a..17173760d5e3d 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 b42932fcc6668..87ed61776ea9c 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 e3d4f672016c0..12c541fbd776d 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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf49..48c4a5c240ea9 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 75b33d6139bc9..b8d0970f4dc3c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -744,10 +744,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 770359b3e9307..70dc2e8d750ef 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.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 09e1d7674044f..1070b49a0dcf2 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { expect, it } from "@effect/vitest"; import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -167,3 +168,64 @@ it.effect("keeps the task list order when a toggle or edit rewrites updated_at", }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); + +it.effect( + "idle polls still wake for tasks saved later, recorded due times, and beside far-future tasks", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-07-01T09:00:00.000Z")); + const launched = yield* Deferred.make(); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: (input) => + Deferred.succeed(launched, input.title).pipe( + Effect.as({ threadId: "thread-idle-poll", resumed: false } as never), + ), + }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + // The first poll finds no tasks and remembers that nothing is due. + yield* TestClock.adjust("6 seconds"); + // Its year-10000+ next run sorts first as text; it must not hide the task below. + yield* service.upsert( + yield* decodeUpsertInput({ + title: "Far future", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 3e14 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + const created = yield* service.upsert( + yield* decodeUpsertInput({ + title: "Idle poll", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "interval", everyMs: 60_000 }, + projectId: "project-idle-poll", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }), + ); + // This poll records the new task's due time; the later ones must still fire it. + yield* TestClock.adjust("6 seconds"); + yield* TestClock.adjust("60 seconds"); + expect(yield* Deferred.await(launched)).toBe(created.task.title); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index c1dcdd22abbae..82d165972750f 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 3dfedb0dfeaf4..f05811ed65110 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, @@ -240,6 +241,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, @@ -390,6 +396,35 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct return tasks; }); +// Backstop for a write path that forgets to invalidate; the tick still runs every few seconds. +const DUE_HINT_MAX_AGE_MS = 10 * 60 * 1_000; + +/** + * Remembers when a poller next has work so idle scheduler ticks skip SQL. + * `invalidate` after every write that can move work earlier; a tick records + * only if no write happened since it read `version`. + */ +const makeDueHint = () => { + let version = 0; + let hint: { readonly dueAtMs: number; readonly checkedAtMs: number } | null = null; + return { + invalidate: Effect.sync(() => { + version++; + hint = null; + }), + version: Effect.sync(() => version), + isIdle: (nowMs: number) => + Effect.sync( + () => + hint !== null && hint.dueAtMs > nowMs && nowMs - hint.checkedAtMs < DUE_HINT_MAX_AGE_MS, + ), + record: (readVersion: number, dueAtMs: number, nowMs: number) => + Effect.sync(() => { + if (readVersion === version) hint = { dueAtMs, checkedAtMs: nowMs }; + }), + }; +}; + export const layer = Layer.effect( ScheduledTaskService, Effect.gen(function* () { @@ -420,7 +455,13 @@ export const layer = Layer.effect( const activeRuns = yield* Ref.make>(new Set()); // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); - const notifyChanged = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); + const dueTasks = makeDueHint(); + const pendingWebhooks = makeDueHint(); + // Every scheduled_tasks write that can change next_run_at, enabled or run state notifies. + const notifyChanged = dueTasks.invalidate.pipe( + Effect.andThen(PubSub.publish(changesPubSub, undefined)), + Effect.asVoid, + ); const selectAllRows = () => sql` @@ -808,6 +849,42 @@ export const layer = Layer.effect( // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; + // 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 @@ -835,20 +912,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"; @@ -941,6 +1007,28 @@ export const layer = Layer.effect( const runDueTasks = Effect.fn("ScheduledTaskService.runDueTasks")(function* () { const now = yield* localNow; + const nowMs = DateTime.toEpochMillis(now); + if (yield* dueTasks.isIdle(nowMs)) return; + const readVersion = yield* dueTasks.version; + const [next] = yield* sql<{ next_run_at: string | null }>` + SELECT MIN(next_run_at) AS next_run_at FROM scheduled_tasks + WHERE enabled = 1 AND next_run_at IS NOT NULL AND last_run_status <> 'running' + `.pipe( + NodeSqliteClient.readOnly, + Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), + ); + // Compare as text like listDueTasks: expanded years (`+011533-โ€ฆ`) sort first but parse far ahead. + const nextRunAt = next?.next_run_at ?? null; + if (nextRunAt === null || nextRunAt > iso(now)) { + const nextDueAt = + nextRunAt === null + ? Option.some(Infinity) + : DateTime.make(nextRunAt).pipe(Option.map(DateTime.toEpochMillis)); + if (Option.isSome(nextDueAt) && nextDueAt.value > nowMs) { + yield* dueTasks.record(readVersion, nextDueAt.value, nowMs); + } + return; + } const tasks = yield* listDueTasks(now).pipe( Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); @@ -1262,6 +1350,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) { @@ -1648,6 +1752,8 @@ export const layer = Layer.effect( const triggerWebhook: ScheduledTaskService["Service"]["triggerWebhook"] = (request) => triggerWebhookUnobserved(request).pipe( + // Accepted deliveries may have queued a coalescing window. + Effect.ensuring(pendingWebhooks.invalidate), Effect.tapError(() => observeDelivery(request, "error", undefined, undefined)), Metrics.withMetrics({ timer: Metrics.webhookDeliveryDuration }), Effect.withSpan("ScheduledTaskService.triggerWebhook", { @@ -2043,10 +2149,19 @@ export const layer = Layer.effect( const dispatchPending = Effect.fn("ScheduledTaskService.dispatchPendingWebhooks")(function* () { const now = DateTime.toEpochMillis(yield* localNow); + if (yield* pendingWebhooks.isIdle(now)) return; + const readVersion = yield* pendingWebhooks.version; yield* sql`UPDATE scheduled_task_webhook_pending SET state = 'dispatching' WHERE state = 'pending' AND due_at <= ${now}`; const tasks = yield* sql<{ task_id: ScheduledTaskId }>` SELECT DISTINCT task_id FROM scheduled_task_webhook_pending WHERE state IN ('dispatching', 'running', 'completed', 'failed') `.pipe(NodeSqliteClient.readOnly); + // Workers can leave rows behind to retry, so keep polling while any are claimed. + if (tasks.length === 0) { + const [next] = yield* sql<{ due_at: number | null }>` + SELECT MIN(due_at) AS due_at FROM scheduled_task_webhook_pending WHERE state = 'pending' + `.pipe(NodeSqliteClient.readOnly); + yield* pendingWebhooks.record(readVersion, next?.due_at ?? Infinity, now); + } yield* Effect.forEach( tasks, ({ task_id: taskId }) => @@ -2138,6 +2253,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 1959735425515..6d89f45cb792e 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 c17198067853c..a331154096398 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -154,6 +154,7 @@ it.effect("parks automatic pull until activation without delaying command readin 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 131f158228755..c703a05bdc7f4 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -185,13 +185,8 @@ const runBootstrapStartup = ( }), }), 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 5fe9d09ea5bb0..599725d49f03f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -154,11 +154,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", { @@ -280,8 +280,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 abafbc7e24f62..b75ab72594add 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 bd985cab203ac..8b7d81859b70d 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; @@ -1004,6 +1047,54 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); + it.effect("reads settings secrets once per settings change", () => { + let reads = 0; + const layerSecret = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.ServerSecretStore, (store) => ({ + ...store, + get: (name: string) => + Effect.suspend(() => { + reads++; + return store.get(name); + }), + })), + ).pipe(Layer.provide(ServerSecretStore.layer)); + const layerSettings = ServerSettingsModule.layer.pipe( + Layer.provide(layerSecret), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-settings-secret-cache-test-" }), + ), + ), + ); + return Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_cached"); + const service = yield* ServerSettingsModule.ServerSettingsService; + const save = (value: string) => + service.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "API_TOKEN", value, sensitive: true }], + config: {}, + }, + }, + }); + const token = service.getSettings.pipe( + Effect.map((settings) => settings.providerInstances[instanceId]?.environment?.[0]?.value), + ); + yield* save("sk-first"); + reads = 0; + assert.equal(yield* token, "sk-first"); + assert.equal(yield* token, "sk-first"); + assert.equal(reads, 0); + yield* save("sk-second"); + assert.equal(yield* token, "sk-second"); + }).pipe(Effect.provide(layerSettings)); + }); + it.effect("keeps the inline value on disk when secret migration fails", () => { const cause = new ServerSecretStore.SecretStorePersistError({ resource: "provider environment secret", @@ -1535,6 +1626,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fileSystem = yield* FileSystem.FileSystem; let failRename = false; let settingsPathToFail: string | undefined; + let readDuringWrite: Effect.Effect = Effect.void; const writeFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -1545,7 +1637,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ...fileSystem, rename: (fromPath, toPath) => failRename && toPath === settingsPathToFail - ? Effect.fail(writeFailure) + ? readDuringWrite.pipe(Effect.andThen(Effect.fail(writeFailure))) : fileSystem.rename(fromPath, toPath), }); const instanceId = ProviderInstanceId.make("antigravity_write_failure"); @@ -1567,6 +1659,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); failRename = true; + readDuringWrite = Effect.ignore(serverSettings.getSettings); const failedUpdate = yield* serverSettings .updateProviderInstance({ operation: "upsert", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 3131c84d880e7..48839bb8535d9 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", ]); @@ -832,6 +851,31 @@ const make = Effect.gen(function* () { }, ); + // Background sweeps read settings every few seconds; reading secret files each time is wasted. + // Every secret write rewrites the settings file, so a new cached object implies fresh secrets. + // A read that overlaps a secret write may see files a rollback then restores, so it is not kept. + let materialized: { readonly source: ServerSettings; readonly value: ServerSettings } | null = + null; + let secretWriteGeneration = 0; + const invalidateMaterialized = Effect.sync(() => { + secretWriteGeneration++; + materialized = null; + }); + const getMaterializedSettings = getSettingsFromCache.pipe( + Effect.flatMap((source) => { + if (materialized?.source === source) return Effect.succeed(materialized.value); + const generation = secretWriteGeneration; + return materializeSettingsSecrets(source).pipe( + Effect.map(resolveTextGenerationProvider), + Effect.tap((value) => + Effect.sync(() => { + if (generation === secretWriteGeneration) materialized = { source, value }; + }), + ), + ); + }), + ); + const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => @@ -1007,8 +1051,9 @@ const make = Effect.gen(function* () { const updated = yield* update(current); const persisted = yield* persistSettingsSecrets(current, updated); const next = yield* normalizeServerSettings(persisted.settings); - const materialized = yield* Effect.uninterruptibleMask(() => + const materializedNext = yield* Effect.uninterruptibleMask(() => Effect.gen(function* () { + yield* invalidateMaterialized; const rollbackSecretChanges = yield* applySettingsSecretChanges(persisted.changes); const materializedExit = yield* Effect.exit(materializeSettingsSecrets(next)); if (Exit.isFailure(materializedExit)) { @@ -1021,22 +1066,18 @@ const make = Effect.gen(function* () { return yield* Effect.failCause(writeExit.cause); } return materializedExit.value; - }), + }).pipe(Effect.ensuring(invalidateMaterialized)), ); yield* Cache.set(settingsCache, cacheKey, { settings: next, parseFailure: Option.none() }); + const resolved = resolveTextGenerationProvider(materializedNext); + materialized = { source: next, value: resolved }; yield* emitChange(next); - return resolveTextGenerationProvider(materialized); + return resolved; }), ); const withSettingsSnapshot: ServerSettingsService["Service"]["withSettingsSnapshot"] = (use) => - writeSemaphore.withPermits(1)( - getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - Effect.flatMap(use), - ), - ); + writeSemaphore.withPermits(1)(getMaterializedSettings.pipe(Effect.flatMap(use))); const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { @@ -1141,10 +1182,7 @@ const make = Effect.gen(function* () { return { start, ready: Deferred.await(startedDeferred), - getSettings: getSettingsFromCache.pipe( - Effect.flatMap(materializeSettingsSecrets), - Effect.map(resolveTextGenerationProvider), - ), + getSettings: getMaterializedSettings, updateSettings: (patch) => updateAndPersistSettings((current) => Effect.succeed(applyServerSettingsPatch(current, patch)), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index bd1453a948f3b..ef27b7eee8c6d 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( @@ -471,3 +476,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 4a9105e9f5d9e..b0770b06d4830 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 b6e7679037040..44d31a43b6978 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 435b14c805bdf..6876729e5fe69 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -16,6 +16,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"; @@ -32,8 +34,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 editBeforeRemoval = false; let openSessionCwd: string | null = null; 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 context = yield* Layer.build(StorageCleanup.layer).pipe( @@ -344,7 +352,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), @@ -368,29 +377,39 @@ 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, - ].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, + ].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; }, @@ -430,6 +449,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; }, @@ -934,6 +959,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); @@ -1823,6 +2164,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 be14cd9d3cd17..bc3dcb19bb23c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -710,7 +710,10 @@ const make = Effect.gen(function* () { now: number, entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; + if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) + return { waiting: false, kept: [] }; + let waiting = false; + const kept: string[] = []; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -784,7 +787,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"); @@ -795,10 +806,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)); @@ -822,7 +833,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 && @@ -929,7 +940,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) @@ -937,10 +948,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) => @@ -965,7 +979,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 @@ -981,7 +995,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; }); @@ -1041,6 +1055,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, @@ -1054,6 +1069,7 @@ const make = Effect.gen(function* () { yield* projectStore.list({ includeDeleted: true }), entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1064,8 +1080,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 { @@ -1093,14 +1111,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({ @@ -1115,9 +1140,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, @@ -1258,14 +1287,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 a1e0e945538d9..bdc0940b89a0b 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 38c39d892defe..b2348fc325b43 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1072,6 +1072,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; @@ -1120,7 +1124,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) { @@ -1182,10 +1186,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; @@ -1236,10 +1241,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 395a24eeedc8d..3fcafcc151789 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 670c2eb28d513..921a2f118e34a 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 a83e09a4110df..04ae83952b3c4 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 96f266c6f8959..fc8d0fb68da31 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 2e3c4c0287d82..bec3f47690895 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 dfcecda3a441d..957e12f3e67af 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import { type ProviderInstanceConfig, ProviderInstanceId, @@ -177,6 +175,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(); @@ -199,6 +198,37 @@ 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"); + const explicitHostId = 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; @@ -686,7 +716,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureScanCacheLoaded; - const hostId = NodeOS.hostname(); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 0e3d2b5063ed2..88ca1eed8f5ea 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, @@ -1002,6 +1006,24 @@ it.effect("GitVcsDriver treats a missing git executable as outside any repositor }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "GitVcsDriver detects a repository whose path contains a newline", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-newline-repo-" }); + const cwd = path.join(parent, "multi\nline"); + yield* fileSystem.makeDirectory(cwd); + yield* runGit(cwd, ["init"]); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + const repository = yield* driver.detectRepository(cwd); + assert.strictEqual(repository?.rootPath, yield* fileSystem.realPath(cwd)); + assert.strictEqual(repository?.metadataPath, ".git"); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "GitVcsDriver still fails detection when git exists but cannot be spawned", () => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index df0a0dd14497d..ddacd87a6d055 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -587,25 +587,43 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { - return null; - } - - const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); - const gitCommonDir = yield* gitCommand( + // One spawn answers all three; outside a work tree it exits non-zero or prints "false". + const result = yield* gitCommand( vcsProcess, - "GitVcsDriver.detectRepository.commonDir", + "GitVcsDriver.detectRepository", cwd, - ["rev-parse", "--git-common-dir"], - ).pipe(Effect.orElseSucceed(() => null)); + ["rev-parse", "--is-inside-work-tree", "--show-toplevel", "--git-common-dir"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16_384, + }, + ).pipe(Effect.catchIf(isMissingExecutable, () => Effect.succeed(null))); + if (result === null || result.exitCode !== 0) return null; + const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u); + if (lines[0]?.trim() !== "true") return null; + // A newline inside a path breaks the line framing; ask for each path on its own. + if (lines.length !== 3) { + const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.orElseSucceed(() => null)); + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + } return { kind: "git" as const, - rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, + rootPath: lines[1]!.trim(), + metadataPath: lines[2]!.trim() || null, freshness: yield* nowFreshness(), }; }); @@ -899,7 +917,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 +927,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 18709ecf0e290..cbc3efcc3b4e7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -404,6 +404,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 @@ -1002,6 +1003,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* () { @@ -2279,6 +2319,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(); @@ -3471,6 +3527,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 f0b78aa564fb5..03f0286ce50d7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -514,6 +514,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}/`; @@ -1412,7 +1421,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, @@ -1443,11 +1452,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, @@ -1938,7 +1944,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({ @@ -1960,7 +1966,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, @@ -1972,12 +1978,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 = @@ -2086,12 +2091,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* lockError; } } - const statusResult = yield* executeGitWithStableDiagnostics( + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), + ); + const statusCacheKey = repositoryPaths?.gitCommonDir; + const readStatus = executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.status", cwd, statusArgs, { allowNonZeroExit: true, + maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, }, ).pipe( Effect.catchTags({ @@ -2099,57 +2109,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), }), ); - - if (statusResult === null) { - return NON_REPOSITORY_STATUS_DETAILS; - } - - if (statusResult.exitCode !== 0) { - if (isNonRepositoryGitStderr(statusResult.stderr)) { - return NON_REPOSITORY_STATUS_DETAILS; - } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetails.status", - cwd, - args: statusArgs, - }), - detail: "Git status failed.", - exitCode: statusResult.exitCode, - stdoutLength: statusResult.stdout.length, - stderrLength: statusResult.stderr.length, - }); - } - - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + // diff-index does not read status output, so both spawn together; a non-repository discards it. + const [statusResult, numstatResult, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ + readStatus, // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. executeGitWithStableDiagnostics( "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), @@ -2171,6 +2158,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); }), + Effect.result, ), statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) @@ -2181,6 +2169,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { concurrency: "unbounded" }, ); + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } + + if (statusResult.exitCode !== 0) { + if (isNonRepositoryGitStderr(statusResult.stderr)) { + return NON_REPOSITORY_STATUS_DETAILS; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.status", + cwd, + args: statusArgs, + }), + detail: "Git status failed.", + exitCode: statusResult.exitCode, + stdoutLength: statusResult.stdout.length, + stderrLength: statusResult.stderr.length, + }); + } + + const numstatEntries = yield* Effect.fromResult(numstatResult); const statusStdout = statusResult.stdout; let refName: string | null = null; @@ -2224,25 +2234,34 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const fallbackAheadCount = - includeDivergence && !upstreamRef && refName - ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) - : null; - - if (fallbackAheadCount !== null) { - aheadCount = fallbackAheadCount; - behindCount = 0; - } - const isDefaultBranch = refName !== null && (refName === defaultBranch || (defaultBranch === null && (refName === "main" || refName === "master"))); - if (includeDivergence && refName && !isDefaultBranch) { - aheadOfDefaultCount = - fallbackAheadCount !== null - ? fallbackAheadCount - : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); + const needsFallbackAhead = includeDivergence && !upstreamRef && refName !== null; + const needsAheadOfDefault = includeDivergence && refName !== null && !isDefaultBranch; + const [baseAheadCount, branchChanges] = yield* Effect.all( + [ + refName !== null && (needsFallbackAhead || needsAheadOfDefault) + ? computeAheadCountAgainstBase(cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => 0)) + : Effect.succeed(0), + options?.includeBranchChanges + ? readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName, { + knownOriginDefaultBranch: defaultBranch, + }).pipe(Effect.orElseSucceed(() => undefined)) + : Effect.undefined, + ], + { concurrency: "unbounded" }, + ); + + if (needsFallbackAhead) { + aheadCount = baseAheadCount; + behindCount = 0; + } + if (needsAheadOfDefault) { + aheadOfDefaultCount = baseAheadCount; } const fileStatMap = new Map(); @@ -2269,12 +2288,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); - const branchChanges = options?.includeBranchChanges - ? yield* readBranchChangeTotals(repositoryPaths?.worktreeRoot ?? cwd, refName).pipe( - Effect.orElseSucceed(() => undefined), - ) - : undefined; - return { isRepo: true, hasOriginRemote: hasPrimaryRemote, @@ -2439,7 +2452,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; @@ -2503,6 +2516,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); @@ -2781,9 +2795,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()), ), @@ -3006,6 +3020,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, branch: string | null, explicitBaseRef?: string, + knownOriginDefaultBranch: string | null = null, ) { const base = explicitBaseRef !== undefined @@ -3017,9 +3032,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : explicitBaseRef, }) : branch - ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { allowRemoteOfCurrent: true }).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* resolveBaseBranchForNoUpstream(cwd, branch, { + allowRemoteOfCurrent: true, + knownOriginDefaultBranch, + }).pipe(Effect.orElseSucceed(() => null)) : null; if (base === null) return { baseRef: null, mergeBase: "HEAD" }; const { baseRef, comparisonRef } = base; @@ -3050,8 +3066,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readBranchChangeTotals = Effect.fn("readBranchChangeTotals")(function* ( cwd: string, branch: string | null, + options?: { readonly knownOriginDefaultBranch: string | null }, ) { - const { baseRef, mergeBase } = yield* resolveReviewMergeBase(cwd, branch); + const { baseRef, mergeBase } = yield* resolveReviewMergeBase( + cwd, + branch, + undefined, + options?.knownOriginDefaultBranch ?? null, + ); const untracked = yield* prepareUntrackedReviewIndex(cwd, []); if (untracked === null) { return yield* new GitCommandError({ @@ -3806,7 +3828,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; @@ -3825,7 +3846,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/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index e5c8169774830..b6f7dae813744 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -21,6 +21,8 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ const normalizeGitArgs = (args: ReadonlyArray): ReadonlyArray => args[0] === "-C" && args.length >= 2 ? args.slice(2) : args; +const DETECT_COMMAND = "rev-parse --is-inside-work-tree --show-toplevel --git-common-dir"; + describe("VcsDriverRegistry", () => { const makeDiskBackedLayer = (rootDir: string, calls: string[]) => Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( @@ -49,10 +51,10 @@ describe("VcsDriverRegistry", () => { stderr: "fatal: not a git repository", }; } - if (command === "rev-parse --is-inside-work-tree") return processOutput("true\n"); - if (command === "rev-parse --show-toplevel") return processOutput(`${repoDir}\n`); - if (command === "rev-parse --git-common-dir") { - return processOutput(`${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`); + if (command === DETECT_COMMAND) { + return processOutput( + `true\n${repoDir}\n${path.relative(input.cwd, path.join(repoDir, ".git"))}\n`, + ); } return processOutput(""); }).pipe(Effect.provide(NodeServices.layer), Effect.orDie), @@ -75,7 +77,7 @@ describe("VcsDriverRegistry", () => { yield* fs.remove(path.join(repoDir, ".git"), { recursive: true }); assert.equal(yield* registry.detect({ cwd: repoDir }), null); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -118,7 +120,7 @@ describe("VcsDriverRegistry", () => { yield* fs.makeDirectory(path.join(repoDir, ".git")); assert.equal((yield* registry.detect({ cwd: repoDir }))?.repository.rootPath, repoDir); - assert.equal(calls.filter((call) => call === "rev-parse --is-inside-work-tree").length, 2); + assert.equal(calls.filter((call) => call === DETECT_COMMAND).length, 2); }).pipe(Effect.provide(makeDiskBackedLayer(repoDir, calls))); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index e2055e3400531..00218397481ff 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -83,8 +83,7 @@ const gitMetricOperations = new Set([ "GitVcsDriver.checkpoints.resolveGitCommonDir", "GitVcsDriver.checkpoints.resolveHeadCommit", "GitVcsDriver.checkpoints.restoreCheckpoint", - "GitVcsDriver.detectRepository.commonDir", - "GitVcsDriver.detectRepository.root", + "GitVcsDriver.detectRepository", "GitVcsDriver.filterIgnoredPaths", "GitVcsDriver.initRepository", "GitVcsDriver.isInsideWorkTree", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index cc1b6d24e3873..042f8274543e5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -76,6 +76,7 @@ function layerTestFor(state: { backgroundWorkEnabled?: boolean; automaticFetchInterval?: Effect.Effect; beforeRemoteStatus?: Effect.Effect; + beforeLocalStatus?: Effect.Effect; fileSystem?: FileSystem.FileSystem; pullCurrentBranch?: GitWorkflowService.GitWorkflowService["Service"]["pullCurrentBranch"]; }) { @@ -95,9 +96,11 @@ function layerTestFor(state: { Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => - Effect.sync(() => { + Effect.suspend(() => { state.localStatusCalls += 1; - return state.currentLocalStatus; + return (state.beforeLocalStatus ?? Effect.void).pipe( + Effect.as(state.currentLocalStatus), + ); }), currentBranch: () => Effect.sync(() => state.currentLocalStatus.refName), remoteStatus: (_input, options) => @@ -163,6 +166,47 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("refreshes queued behind a running one share the next git status read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeLocalStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const running = yield* broadcaster + .refreshLocalStatus("/repo") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.await(started); + // Requested while the first read runs, so that read may predate their change. + state.currentLocalStatus = { ...baseLocalStatus, hasWorkingTreeChanges: true }; + const queued = yield* Effect.all( + [broadcaster.refreshLocalStatus("/repo"), broadcaster.refreshLocalStatus("/repo")], + { concurrency: "unbounded" }, + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + + assert.isFalse((yield* Fiber.join(running)).hasWorkingTreeChanges); + const [second, third] = yield* Fiber.join(queued); + assert.isTrue(second.hasWorkingTreeChanges); + assert.isTrue(third.hasWorkingTreeChanges); + assert.strictEqual(state.localStatusCalls, 2); + + yield* broadcaster.refreshLocalStatus("/repo"); + assert.strictEqual(state.localStatusCalls, 3); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("publishes a terminal branch switch without reading remote status", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 8ce1b225d123d..a8839d0c1cd0c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -359,11 +359,30 @@ export const make = Effect.gen(function* () { ); }); + // A refresh that started after a request saw every change that request could mean, so queued + // callers share it instead of each running git status again. + const localRefreshLocks = yield* KeyedLock.make(); + const localRefreshRequests = new Map(); + const localRefreshResults = new Map< + string, + { readonly startedAt: number; readonly local: VcsStatusLocalResult } + >(); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + const requestedAt = (localRefreshRequests.get(cwd) ?? 0) + 1; + localRefreshRequests.set(cwd, requestedAt); + return yield* localRefreshLocks.withLock( + cwd, + Effect.gen(function* () { + const shared = localRefreshResults.get(cwd); + if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local; + const startedAt = localRefreshRequests.get(cwd) ?? requestedAt; + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + localRefreshResults.set(cwd, { startedAt, local }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }), + ); }, ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 8922c55868391..8c18cb2181c9f 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -50,6 +50,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"; @@ -412,12 +413,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: [], }), ), @@ -595,6 +607,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"), @@ -771,6 +784,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 7aaa9f33efbed..48dc9235563a6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -125,7 +125,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, @@ -135,7 +134,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -215,6 +214,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"; @@ -1010,21 +1010,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, @@ -1239,6 +1241,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; @@ -1791,6 +1794,7 @@ export const layerWsRpc = ( threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -2727,6 +2731,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) => @@ -3031,6 +3043,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 a4a83248087fb..9ef9b92b0ffaf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.76", + "version": "0.0.77", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c7c0f77bc97fb..bef8f7ab405f1 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 77849dc121da0..578a93d15d4a7 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"; @@ -189,6 +190,7 @@ import { newMessageId } from "~/lib/utils"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, + resolveProjectDefaultProviderModelSelection, shouldShowInstanceBadge, } from "../providerInstances"; import { @@ -304,7 +306,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 { @@ -401,6 +404,8 @@ type ChatViewProps = draftId: DraftId; }; +const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -587,6 +592,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; @@ -752,15 +760,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; @@ -1073,7 +1103,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); }; @@ -1663,6 +1696,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 @@ -2347,7 +2385,6 @@ export default function ChatView(props: ChatViewProps) { closeRightPanelSurface, }); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; - const nowMinute = useNowMinute(); const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2517,12 +2554,58 @@ 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]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2531,6 +2614,7 @@ export default function ChatView(props: ChatViewProps) { } return [ ...feedbackBannerItems, + ...fallbackItems, ...limitRecoveryItems, ...usageLimitsItems, ...projectCloneItems, @@ -2595,6 +2679,11 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "issues"); }); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, @@ -3202,6 +3291,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> @@ -3477,7 +3567,7 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={composerBannerItems} + bannerItems={isDraftHeroState ? [] : composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -3649,6 +3739,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 fa9cbf0141c3e..88793cce44850 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 944af8714b84e..d522d5f6ef44a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -93,6 +93,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; @@ -243,17 +244,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 5f793b3ca90e4..ed3a723652a56 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 55006d84a0707..ede0b4b8d3f1f 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, @@ -26,6 +27,8 @@ import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; +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"; @@ -42,6 +45,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."; } @@ -53,6 +79,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"> & { @@ -80,7 +112,13 @@ export function useServerUpdate() { pendingUpdateEnvironmentIds.add(environmentId); 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") { @@ -119,12 +157,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( (target) => target.selfUpdate !== null && - (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { if (pending.current) return; @@ -199,6 +239,7 @@ export function ServerUpdateAction({ installation, desktopAppUpdate = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", @@ -206,6 +247,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 canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); const update = useServerUpdate(); @@ -265,6 +307,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -352,6 +398,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 6e278b22917cc..ecd1a48b74beb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -241,7 +241,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 ec92540efd1b0..144402d1dfe9f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -1,6 +1,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, + type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -54,6 +55,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -80,6 +82,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; @@ -196,6 +199,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 ef07497171ee2..0710f2534dada 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 086826cde86db..755d20ba8c6ae 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 52800d94a8bca..58ad8e8c0900d 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 697a0e0bfd6f2..e3e14c4a84005 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 13614b8c9bd4c..213189064ee11 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 294816c140a29..12c11d213cdf7 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 0000000000000..2ea8bcdb83559 --- /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 9e4c0f7cef5e9..2b60c35723a6f 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 4a8a894bedbb6..d2e259692204b 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -25,7 +25,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"; @@ -45,6 +49,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"; @@ -67,6 +73,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); @@ -193,13 +200,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 3ee079809f14e..8f4b15cc11f91 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 f4b409f901b53..c26bcf51a718a 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( @@ -1772,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" || @@ -1798,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/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index b34867b2668f3..720ce4d6c443d 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 8c8249c67b1a0..833c3f3784987 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 20ecde3272276..ca2497bd20ee8 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 37110875ed0ea..a8728d7879dd4 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 4add56b80eb08..a5df136f14615 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 0000000000000..d84c13809eaf6 --- /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 1463c97f9ff6f..35a954db9d43e 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -16,13 +16,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); @@ -67,12 +79,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 = { @@ -106,97 +121,181 @@ 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 00aacd5a7b152..de5ce6c63825d 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 ae2e232042e96..9c64f312c63a7 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -9,7 +9,7 @@ import type { 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 { WorkflowScriptView } from "./WorkflowScriptView"; @@ -21,6 +21,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, @@ -180,7 +181,7 @@ export function ThreadLineageWorkflowRow({ } > {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 66377441dad3a..2e555c4fd37ad 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, @@ -890,7 +890,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -929,7 +929,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 24704f7ec398d..81cca27e72747 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -10,6 +10,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"; @@ -348,6 +349,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 76be389cdd46d..3351b9fc217ef 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -35,7 +35,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; @@ -745,7 +745,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 f02780bed3af1..79a29d293dc49 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 22cb582493df2..3bc371b5c1ab9 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 76e10839bb2c8..f9f3c9a5ac40a 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 dd644ccf0c822..851daaebe5eab 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/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d1b165c62147b..94f4ccda82558 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 c905488949cff..21765c68d18e8 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 ec3319f010843..59d7e0c83ad12 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 c4e917fa7134e..fe22fe5af2838 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 0000000000000..721ce2a84a6be --- /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("cliproxy", 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("cliproxy", 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 0000000000000..b7fb55a6e39ef --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts @@ -0,0 +1,31 @@ +import { + type UnifiedSettings, + UsageLimitSourceId, + type UsageLimitSourceKind, +} from "@t3tools/contracts"; + +export function sourceIdFromUrl( + kind: UsageLimitSourceKind, + url: string, + sources: UnifiedSettings["usageLimitSources"], +): UsageLimitSourceId { + const saved = Object.entries(sources).find( + ([, source]) => source.kind === kind && 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 = `${kind}-${slug || "source"}`; + 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 1cf549f4c856e..4356c5b494298 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,8 +1,4 @@ -import { - type EnvironmentId, - UsageLimitSourceId, - type UsageLimitSourceKind, -} from "@t3tools/contracts"; +import type { EnvironmentId, UnifiedSettings, UsageLimitSourceKind } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -17,26 +13,11 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; -/** - * Dots and dashes in the host are kept so `foo-bar.com` and `foo.bar.com` do - * not collide; anything else (a port's colon, a path) is folded to a dash. - */ -function sourceIdFromUrl(kind: UsageLimitSourceKind, 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(`${kind}-${slug || "source"}`); -} - const KINDS: ReadonlyArray<{ readonly kind: UsageLimitSourceKind; readonly label: string; @@ -72,11 +53,13 @@ export function AddUsageLimitSourceDialog({ 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 [kind, setKind] = useState("cliproxy"); @@ -96,8 +79,7 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(kind, trimmedUrl); - // The patch names only this entry; the server merges it into its map. + const id = sourceIdFromUrl(kind, trimmedUrl, sources); updateSettings({ usageLimitSources: { [id]: { diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index f47ee3f52c793..1c5812a2bda0d 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} @@ -1004,6 +1004,16 @@ function ManagedCodexSetup({ Use a different account ) : null} + {allowExistingCli ? ( + + ) : null} )}
diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 69e23c3cdf964..09ccc32fd61b9 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -507,14 +507,8 @@ export function DeviceSourcesSettings(props: { label: member.label, added: true, platforms: hostPlatforms(member.state, host.id), - status: - held.agentToken === "" - ? ({ - tone: "warning", - label: "No agent token", - ...(disabled ? {} : { action: { label: "Add token", run: edit } }), - } as const) - : hostStatus(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)), diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 0bfa19498f6d9..43c4419962a02 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -77,6 +77,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"]); @@ -216,6 +224,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 e42242f01093e..124a27c7b11f4 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"); @@ -38,7 +40,23 @@ const task: ScheduledTask = { runCount: 0, }; -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 { @@ -54,8 +72,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: [], @@ -68,15 +90,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 ?? "")!, }, }, }; @@ -93,7 +114,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()), @@ -105,9 +127,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("Runs on")).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("Runs on")).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"); @@ -124,7 +181,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 1f64fe501af1d..e027438a6abdb 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -396,9 +396,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 }) } > @@ -464,6 +473,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 0000000000000..a23ea149d480f --- /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 0000000000000..11139d9c7aa67 --- /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 0000000000000..9370fcc68b3ee --- /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 0000000000000..2ff329ca6b73a --- /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 1cbff3d750cf3..30f61ddd80371 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 715d538bb4870..2b8f66f4e04f6 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 { AgentAttentionSettings } from "./AgentAttentionSettings"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; @@ -21,6 +22,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"; @@ -70,7 +72,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"; @@ -101,6 +103,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", @@ -148,39 +157,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; @@ -278,7 +286,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"; @@ -314,23 +323,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={