diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml new file mode 100644 index 0000000000000..e7615d46011bd --- /dev/null +++ b/.github/workflows/mobile-eas-preview-fork.yml @@ -0,0 +1,145 @@ +name: Mobile EAS Preview (Fork) + +# EAS previews for fork PRs. This runs the PR's code with an Expo token: the +# app config and plugins run on this runner, and native builds run on EAS +# with preview signing credentials. The label is the maintainer's sign-off +# that they reviewed that code, so it covers exactly the commit it was applied +# to: this pins that commit and consumes the label before running anything. A +# later push deploys nothing until the label is applied again. +# +# Same-repo PRs deploy on every push while labeled, via mobile-eas-preview.yml. + +on: + pull_request_target: + types: [labeled] + +permissions: + contents: read + +jobs: + preview: + name: EAS Preview + if: >- + github.event.pull_request.head.repo.full_name != github.repository && + github.event.label.name == '๐Ÿš€ Mobile Continuous Deployment' + runs-on: blacksmith-8vcpu-ubuntu-2404 + concurrency: + group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: + contents: read + pull-requests: write + env: + APP_VARIANT: preview + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + steps: + # If a maintainer removed the label first, that removal wins. + - id: consume + name: Consume the deployment label + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: context.payload.label.name, + }); + core.setOutput("consumed", "true"); + } catch (error) { + if (error.status !== 404) throw error; + core.info("The label was removed before this run could consume it. Skipping."); + core.setOutput("consumed", "false"); + } + + - id: expo-token + name: Check for EXPO_PREVIEW_TOKEN + if: steps.consume.outputs.consumed == 'true' + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_PREVIEW_TOKEN is not set; skipping EAS preview." + fi + + # The head SHA from the label event, not the branch, so a push after the + # label cannot swap in unreviewed code. No credentials stay in .git for + # the PR's scripts to read. + - name: Checkout reviewed commit + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + # No sparse-checkout: see mobile-eas-preview.yml. + + # No caches: pull_request_target saves them under main's scope, where + # trusted workflows with other secrets would restore what PR code wrote. + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: | + args: + - --filter=@t3tools/mobile... + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. + eas-version: 21.8.0 + eas-cache: false + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} + packager: npm + + - name: Pull preview environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + run: | + eas env:pull preview --non-interactive + # See mobile-eas-preview.yml: keeps Android build and OTA fingerprints matching. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE + + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} + RUNNER_DEBUG: "1" + # The action only comments for pull_request events; this hook gives + # it the PR under pull_request_target. Without it, it skips the comment. + EXPO_TEST_GITHUB_PULL: ${{ github.event.pull_request.number }} + with: + profile: preview:dev + branch: pr-${{ github.event.pull_request.number }} + platform: all + environment: preview + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index 107c741424b08..08183d0385811 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -7,7 +7,9 @@ on: jobs: preview: name: EAS Preview + # Fork PRs deploy one reviewed commit per label through mobile-eas-preview-fork.yml. if: | + github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, '๐Ÿš€ Mobile Continuous Deployment') && (github.event.action != 'labeled' || github.event.label.name == '๐Ÿš€ Mobile Continuous Deployment') runs-on: blacksmith-8vcpu-ubuntu-2404 @@ -23,15 +25,15 @@ jobs: MOBILE_VERSION_POLICY: fingerprint steps: - id: expo-token - name: Check for EXPO_TOKEN + name: Check for EXPO_PREVIEW_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS preview." + echo "EXPO_PREVIEW_TOKEN is not available; skipping EAS preview." fi - name: Checkout @@ -70,7 +72,7 @@ jobs: # continuous-deploy-fingerprint still reads `build.project.ownerAccount`. # Unpin once https://github.com/expo/expo-github-action/issues/358 is fixed. eas-version: 21.8.0 - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PREVIEW_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -80,7 +82,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} run: | eas env:pull preview --non-interactive # EAS Update disables dotenv loading. Keep the downloaded native config @@ -99,7 +101,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' uses: expo/expo-github-action/continuous-deploy-fingerprint@main env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} # The Expo action otherwise suppresses the EAS command's failure output. RUNNER_DEBUG: "1" with: diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index b2c03c04ab11c..507e013bf1c2a 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -97,13 +97,13 @@ jobs: - id: expo-token name: Check for EXPO_TOKEN env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | if [ -n "$EXPO_TOKEN" ]; then echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_TOKEN is not available; skipping EAS production job." + echo "EXPO_PRODUCTION_TOKEN is not available; skipping EAS production job." fi - id: version_app_token @@ -149,7 +149,7 @@ jobs: uses: expo/expo-github-action@v8 with: eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} + token: ${{ secrets.EXPO_PRODUCTION_TOKEN }} # npm, not pnpm: this only installs eas-cli into the action's own # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's # ignored build script (no allowBuilds config outside the repo). @@ -159,7 +159,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | eas env:pull production --non-interactive # EAS commands skip dotenv, and the Android fingerprint hashes this @@ -244,7 +244,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait @@ -255,7 +255,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && (github.event_name == 'push' || inputs.mode == 'update') working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} PLATFORM: ${{ inputs.platform || 'all' }} run: | if [ "$PLATFORM" = "all" ]; then platforms="ios android"; else platforms="$PLATFORM"; fi @@ -275,7 +275,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} OTA_MESSAGE: ${{ inputs.message || format('Production OTA ({0})', github.sha) }} run: | @@ -299,7 +299,7 @@ jobs: if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' working-directory: apps/mobile env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_TOKEN: ${{ secrets.EXPO_PRODUCTION_TOKEN }} run: | failed=0 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" 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 e8f300242f054..6d89b7c7abe7a 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/apps/desktop/package.json b/apps/desktop/package.json index 7f768c1b44d75..886ccf3cd798f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "dist-electron/boot.cjs", 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..74b447c25bab4 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 { @@ -467,6 +538,9 @@ const themeIconFixture = (source: Uint8Array) => const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-theme-icons-" }); yield* fileSystem.makeDirectory(path.join(root, "resources", "icons"), { recursive: true }); yield* fileSystem.writeFile(path.join(root, "resources", "icons", "256x256.png"), source); + const caseSensitive = !(yield* fileSystem.exists( + path.join(root, "resources", "ICONS", "256x256.png"), + )); const hicolorDir = path.join(root, "icons", "hicolor"); const appsDir = path.join(hicolorDir, "256x256", "apps"); const cachePath = path.join(hicolorDir, "icon-theme.cache"); @@ -479,14 +553,23 @@ const themeIconFixture = (source: Uint8Array) => const rewriteCache = fileSystem .writeFile(cachePath, new Uint8Array([1])) .pipe(Effect.orDie, Effect.provideService(FileSystem.FileSystem, fileSystem)); - return { root, hicolorDir, appsDir, cachePath, pastSeconds, seedCache, rewriteCache }; + return { + root, + hicolorDir, + appsDir, + cachePath, + pastSeconds, + seedCache, + rewriteCache, + caseSensitive, + }; }); const iconCacheRuns = (run: ThemeIconRun) => run.commands.filter(({ command }) => command === "gtk-update-icon-cache").length; it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { - it.effect("installs both app id spellings without creating an icon cache", () => + it.effect("installs both app id lookups without creating an icon cache", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -495,10 +578,12 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { const run: ThemeIconRun = { root: fixture.root, commands: [] }; yield* registerWithRealFileSystem(run); - assert.deepEqual([...(yield* fileSystem.readDirectory(fixture.appsDir))].sort(), [ - "com.t3tools.T3Code.png", - "com.t3tools.t3code.png", - ]); + assert.deepEqual( + [...(yield* fileSystem.readDirectory(fixture.appsDir))].sort(), + fixture.caseSensitive + ? ["com.t3tools.T3Code.png", "com.t3tools.t3code.png"] + : ["com.t3tools.t3code.png"], + ); for (const name of ["com.t3tools.t3code", "com.t3tools.T3Code"]) { const target = path.join(fixture.appsDir, `${name}.png`); assert.deepEqual([...(yield* fileSystem.readFile(target))], [...icon]); @@ -524,7 +609,10 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { const lower = path.join(fixture.appsDir, "com.t3tools.t3code.png"); const upper = path.join(fixture.appsDir, "com.t3tools.T3Code.png"); yield* fileSystem.writeFile(lower, new Uint8Array([3, 2, 1])); - yield* fileSystem.writeFile(upper, new Uint8Array([1, 2, 3])); + // On a case-insensitive volume, writing upper would replace the stale lower fixture. + if (fixture.caseSensitive) { + yield* fileSystem.writeFile(upper, new Uint8Array([1, 2, 3])); + } for (const target of [lower, upper]) { yield* fileSystem.utimes(target, fixture.pastSeconds, fixture.pastSeconds); } @@ -538,8 +626,11 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { yield* registerWithRealFileSystem(run); assert.deepEqual([...(yield* fileSystem.readFile(lower))], [1, 2, 3]); - const upperInfo = yield* fileSystem.stat(upper); - assert.equal(Option.getOrThrow(upperInfo.mtime).getTime(), fixture.pastSeconds * 1000); + assert.deepEqual([...(yield* fileSystem.readFile(upper))], [1, 2, 3]); + if (fixture.caseSensitive) { + const upperInfo = yield* fileSystem.stat(upper); + assert.equal(Option.getOrThrow(upperInfo.mtime).getTime(), fixture.pastSeconds * 1000); + } assert.deepEqual( run.commands.filter(({ command }) => command === "gtk-update-icon-cache"), [{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }], 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/channels.ts b/apps/desktop/src/ipc/channels.ts index 8ca1dea2b52ad..8b01b73ad27a7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -16,6 +16,7 @@ export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const TRACKPAD_SCROLL_BEGIN_CHANNEL = "desktop:trackpad-scroll-begin"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; 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 447015847de15..b96a0dc139270 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + onTrackpadScrollBegin: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); + }, onTrackpadScrollEnd: (listener) => { const handler = () => listener(); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); @@ -264,11 +269,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 4fc1fdd8c1264..1ba5c4c746fc3 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -281,11 +281,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 c80498c14c1fb..ec5062f9105bf 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -65,6 +65,7 @@ const clientSettings: ClientSettings = { 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/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index ab5fc8be26a92..c1c0cf5446cd2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -23,6 +23,7 @@ import { MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_BEGIN_CHANNEL, TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; @@ -652,7 +653,11 @@ export const make = Effect.gen(function* () { } }); window.webContents.on("input-event", (_event, input) => { - if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + if (input.type === "gestureScrollBegin") { + window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); + } else if (input.type === "gestureScrollEnd") { + window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + } }); // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { 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/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 446794800eadb..bd3f04d9d6b4d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -794,7 +794,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps ) : null} - {tab === "guide" ? : null} + {tab === "guide" ? ( + + ) : null} {tab === "activity" ? ( entry.labels), ); - - 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/pullRequestList.test.ts b/apps/mobile/src/features/pull-requests/pullRequestList.test.ts index 0fc6c119190fd..d6ab0f73defc9 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, @@ -28,6 +29,7 @@ import { withListedPullRequests, withShownPullRequestStats, withoutScratchProjectScope, + pullRequestProjectChoices, } from "./pullRequestList"; function entry(number: number, patch: Partial = {}): PullRequestListEntry { @@ -481,6 +483,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 4b7b8c5d1de71..71d6cab1062d3 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, @@ -35,6 +36,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..21b57322da1cd 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}`; } @@ -531,17 +534,22 @@ export function buildReviewSectionItems(input: { }, ); - const gitItems = input.gitSections.map((section) => ({ - id: `git:${section.kind}`, - kind: section.kind, - title: section.title, - subtitle: gitSubtitle(section), - diff: section.diff, - source: section, - ...(section.files ? { files: section.files } : {}), - truncated: section.truncated, - isLoading: false, - })); + const gitItems = input.gitSections + .filter( + (section): section is typeof section & { kind: ReviewSectionKind } => + section.kind !== "commit", + ) + .map((section) => ({ + id: `git:${section.kind}`, + kind: section.kind, + title: section.title, + subtitle: gitSubtitle(section), + diff: section.diff, + source: section, + ...(section.files ? { files: section.files } : {}), + truncated: section.truncated, + isLoading: false, + })); const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID); const visibleGitItems = input.loadingGitSections && !hasChangesItem 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 d86600c4998c6..4a0f0fc26da73 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"; @@ -209,11 +210,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 ? ( @@ -294,7 +298,10 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm void run("refresh", async () => { if (!readEnvironmentScope(environmentId, AuthOrchestrationReadScope)) return; - const result = await refreshProviders({ environmentId, input: {} }); + const result = await refreshProviders({ + environmentId, + input: { reloadSecrets: true }, + }); if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); setNotice("Provider status refreshed."); }); 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 6b7da219c34c7..a1935f11bffeb 100644 --- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx +++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx @@ -91,7 +91,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/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d3783882989d6..f57e1d4f99f12 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -47,6 +47,7 @@ import type { ScopedThreadRef, ServerConfig as T3ServerConfig, UsageLimitsReport, + RunId, } from "@t3tools/contracts"; import { appendCodexArtifactTemplateUsePrompt, @@ -157,8 +158,10 @@ import { import { appAtomRegistry } from "../../state/atom-registry"; import { threadStreamHaptics } from "./threadStreamHaptics"; import { useAtomCommand } from "../../state/use-atom-command"; +import { orchestrationEnvironment } from "../../state/orchestration"; import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import type { RunStopPresentation } from "@t3tools/client-runtime/state/thread-execution"; import type { PendingApproval, PendingUserInput, @@ -348,6 +351,9 @@ const threadVisitTracker = createThreadVisitTracker({ // Past the composer's 180 ms height animation. const COMPOSER_HEIGHT_SETTLE_MS = 220; +const NO_RUN_STOP_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread-run-stop:empty"), +); // Anchors, the submitted message, follow state and pending scrolls belong to // one thread, so the body remounts when the thread changes. @@ -549,6 +555,50 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); + const anyRunStop = useAtomValue( + props.selectedThreadDetailRef === null + ? NO_RUN_STOP_ATOM + : environmentThreadDetails.runStopAtom(props.selectedThreadDetailRef), + ); + // A background Stop matters only while that work is still pending. + const runStop = + anyRunStop?.background && props.selectedThread.pendingBackgroundTasks.length === 0 + ? null + : anyRunStop; + const forceStopThread = useAtomCommand(orchestrationEnvironment.forceStopThread, { + reportFailure: false, + }); + const canForceStop = + useAtomValue(orchestrationEnvironment.forceStopThread.permissionAtom(props.environmentId)) && + props.serverConfig?.environment.capabilities.threadForceStop === true; + const [forceStoppingKey, setForceStoppingKey] = useState(null); + // The run that ignored Stop, so a confirmation left open cannot hit a newer run. + const forceStop = async (runId: RunId | undefined) => { + const threadKey = selectedThreadKey; + setForceStoppingKey(threadKey); + const result = await forceStopThread({ + environmentId: props.environmentId, + input: + runId === undefined + ? { threadId: props.selectedThread.id } + : { threadId: props.selectedThread.id, runId }, + }); + setForceStoppingKey((current) => (current === threadKey ? null : current)); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) Dialog.alert("Could not force stop the agent"); + return; + } + const others = result.value.sharedWithThreadIds.length; + if (result.value.outcome === "releasing") { + Dialog.alert("Turn stopped; the agent is still shutting down"); + } + if (result.value.outcome === "shared") { + Dialog.alert( + "Turn stopped; the agent process kept running", + `It is shared with ${others === 0 ? "other" : others} working ${others === 1 ? "thread" : "threads"}. Stop those to free it.`, + ); + } + }; const [statusRequest, setStatusRequest] = useState<{ threadKey: string } | null>(null); const statusPending = statusRequest?.threadKey === selectedThreadKey; const [lifecycleNow, setLifecycleNow] = useState(() => new Date().toISOString()); @@ -640,6 +690,32 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { if (connectionStatus !== null && !props.draftMode) { return connectionStatus; } + // Before the request check: an agent stuck on an approval may also ignore Stop. + if (runStop !== null && contentPresentationKind === "ready") { + const unresponsive = runStop.state === "unresponsive"; + return { + kind: "stop", + unresponsive, + label: unresponsive ? "Not responding to Stop" : "Stoppingโ€ฆ", + action: + unresponsive && canForceStop + ? { + label: forceStoppingKey === selectedThreadKey ? "Force stopping..." : "Force stop", + accessibilityLabel: "Force stop the agent", + disabled: forceStoppingKey === selectedThreadKey, + onPress: () => + Dialog.confirm({ + title: "Force stop the agent?", + message: + "It has not answered Stop. Force stop ends the turn and kills the agent's processes, unless other working threads share them.", + confirmText: "Force stop", + destructive: true, + onConfirm: () => void forceStop(runStop.runId), + }), + } + : null, + }; + } if (props.activePendingApproval !== null || props.activePendingUserInput !== null) { return null; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 03fe373dd5ce0..8c6813297010c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1641,6 +1641,16 @@ function renderFeedEntry( const visibleAttachments = attachments.filter( (attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id), ); + // Images sent without words show as images, not as a bubble around them. + const mediaOnly = + foldRowId === null && + presentation.attribution !== "guest" && + presentation.text.trim() === "" && + visibleAttachments.length + (entry.pendingMessage?.attachments.length ?? 0) > 0 && + visibleAttachments.every(isImageAttachment) && + (entry.pendingMessage?.attachments ?? []).every( + (attachment) => attachment.type === "image", + ); return ( 0) ? { width: props.duoColumns ? "85%" : props.userBubbleMaxWidth } : null), }} @@ -1685,7 +1695,13 @@ function renderFeedEntry( /> ) : null} {bubbleBodyVisible ? ( - + {entry.pendingMessage?.attachments.map((attachment) => attachment.type === "image" && attachment.uploadedAttachmentId ? ( ) : attachment.type === "image" ? ( @@ -1713,7 +1733,7 @@ function renderFeedEntry( > ) : ( @@ -1734,7 +1754,10 @@ function renderFeedEntry( className={ inlineAttachmentIds.size ? "h-24 w-24 rounded-[14px] bg-user-bubble-foreground/15" - : "aspect-[1.3] w-full rounded-[14px] bg-user-bubble-foreground/15" + : mediaOnly + ? // Matches the bubble's corners; native borders sit inside, so the edge adds no size. + "aspect-[1.3] w-full rounded-[20px] border border-border bg-user-bubble-foreground/15" + : "aspect-[1.3] w-full rounded-[14px] bg-user-bubble-foreground/15" } onPressPreview={props.onPressPreview} /> @@ -1752,7 +1775,7 @@ function renderFeedEntry( })} ) : null} - {message.text.trim().length > 0 ? ( + {presentation.text.trim().length > 0 ? ( diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index a143a2a13c116..24f0de9e75ee1 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -83,7 +83,9 @@ export function FloatingWorkingControl(props: { const [queueWidth, setQueueWidth] = useState(0); const [actionWidth, setActionWidth] = useState(0); const action = - props.status?.kind === "background" || props.status?.kind === "lifecycle" + props.status?.kind === "background" || + props.status?.kind === "lifecycle" || + props.status?.kind === "stop" ? props.status.action : null; const [agentsWidth, setAgentsWidth] = useState(0); @@ -472,6 +474,30 @@ function FloatingStatusLabel(props: { ); } + if (props.status.kind === "stop") { + return ( + + {props.status.unresponsive ? ( + + ) : ( + + )} + + {props.status.label} + + + ); + } if (props.status.kind === "goal") { return ( ({ + 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/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index cbad91119895b..250a935afe7cd 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/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts index bed322f38ad35..4f8b9ba4ce734 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts @@ -1,7 +1,10 @@ import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message"; @@ -37,11 +40,13 @@ export function resolveThreadQueueRowControls(input: { canSend: mutationEnabled && (input.canPromoteToSteer || input.canSendNow) && !isEditing, dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, // Chips read as their labels, matching the composer. - displayText: composerFileMentionsToLabels( - assistantCitationsToPlainText( - replaceComposerContextReferences(input.text, (reference) => reference.label), - ), - ).trim(), + displayText: isAttachmentOnlyBootstrapPrompt(input.text) + ? "" + : composerFileMentionsToLabels( + assistantCitationsToPlainText( + replaceComposerContextReferences(input.text, (reference) => reference.label), + ), + ).trim(), isEditing, }; } 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 : ( { + 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), @@ -732,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"), @@ -794,7 +846,7 @@ describe("buildThreadFeed", () => { expect(collapsed[1]).toMatchObject({ type: "run-fold", createdAt: "2026-06-20T00:00:01.500Z", - label: "Worked for 2.0s", + label: "Worked for 2s", }); const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId])); @@ -1048,10 +1100,10 @@ describe("buildThreadFeed", () => { const settled = deriveThreadFeedPresentation(feed(false), null, new Set()); expect(shape(settled)).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); const launchFold = settled.find((entry) => entry.type === "run-fold"); @@ -1060,11 +1112,11 @@ describe("buildThreadFeed", () => { shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))), ).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "activity-group", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); @@ -1081,7 +1133,7 @@ describe("buildThreadFeed", () => { ), ).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", "activity-group", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 82fbc0dc81400..ded673207c9d6 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -272,6 +272,7 @@ export function workEntryRowLabel( expanded = false, workspaceRoot: string | null = null, ): string { + if (entry.itemType === "fork") return entry.label; if (expanded && entry.itemType === "reasoning") return entry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought"; const presentation = resolveWorkEntryToolPresentation(entry); @@ -2211,10 +2212,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/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/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 bd81fd993c43e..789d612e6fe3b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "repository": { "type": "git", @@ -78,6 +78,9 @@ "@opencode-ai/sdk": "^1.3.15", "@t3tools/contracts": "workspace:*", "@t3tools/provider-testing": "workspace:*", + "@t3tools/secret-provider-core": "workspace:*", + "@t3tools/secret-provider-definitions": "workspace:*", + "@t3tools/secret-provider-onepassword": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-testing": "workspace:*", "@t3tools/ssh": "workspace:*", 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 8d5c5bf8e3e44..ff842c73595a9 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -431,7 +431,7 @@ export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError; + readonly requiredScopes?: ReadonlyArray; }, ): Effect.Effect< ResolvedBootstrapGrant, @@ -923,6 +924,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) => ({ @@ -1343,7 +1350,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" })), @@ -1353,9 +1360,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 70f9942193ef0..f696f8958f961 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -5,7 +5,9 @@ import { AuthProvidersManageScope, AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, ProviderInstanceId, + SecretProviderId, ThreadId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -228,17 +230,38 @@ describe("settings mutation authorization", () => { ); yield* client[WS_METHODS.serverUpdateSettings]({ patch: {}, providerInstanceMutation }); expect(handled).toBe(1); + yield* client[WS_METHODS.serverUpdateSettings]({ + patch: {}, + providerInstanceMutation: { + operation: "upsert", + instanceId: ProviderInstanceId.make("codex_work"), + instance: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { + name: "OPENAI_API_KEY", + sensitive: true, + value: { + provider: SecretProviderId.make("personal"), + reference: "op://Private/OpenAI/credential", + }, + }, + ], + }, + }, + }); + expect(handled).toBe(2); expect( yield* client[WS_METHODS.serverUpdateSettings]({ patch: { defaultRuntimeMode: "full-access" }, providerInstanceMutation, }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthSettingsWriteScope }); - expect(handled).toBe(1); + expect(handled).toBe(2); }).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( @@ -260,6 +283,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 c688bbf3e6587..84592bd6c02b7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -43,6 +43,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, @@ -268,6 +269,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, [WS_METHODS.worktreesList]: AuthOrchestrationReadScope, [WS_METHODS.worktreesSize]: AuthOrchestrationReadScope, + [WS_METHODS.reviewGetBranchCommits]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, @@ -326,7 +328,8 @@ const SettingsUpdate = Schema.Struct({ patch: ServerSettingsPatch, providerInstanceMutation: Schema.optionalKey(ProviderInstanceMutation), }); -const decodeSettingsUpdate = Schema.decodeUnknownSync(SettingsUpdate); +// Middleware receives the payload already decoded, so it is checked against the decoded shape. +const decodeSettingsUpdate = Schema.decodeUnknownSync(Schema.toType(SettingsUpdate)); const requiredScopesForSettingsUpdate = (payload: unknown) => { const input = decodeSettingsUpdate(payload); diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 63336b29c9005..30784ab33ceb0 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 9eb1b9c4baafe..e3e7541d49f00 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -9,6 +9,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"; @@ -73,6 +74,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, ); @@ -118,7 +128,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 5ad9bde0acc28..88fc9cec74c13 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/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 033a96693ada9..2ea799d9f09e4 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -90,6 +90,37 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands }), ); + it.effect("protect refuses root before creating any T3 directories", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const baseDir = `${parent}/t3`; + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, makeTestService(status).service), + ); + + const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "protect", + "--base-dir", + baseDir, + ]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.map((lines) => lines.join("\n")), + Effect.provideService(HostProcess.Environment, {}), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.UserId, 0), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + expect(output).toContain("without sudo"); + expect(yield* fs.exists(baseDir)).toBe(false); + }), + ); + // regression: #13239 a stale nvm-installed CLI reported "already using" its own version it.effect.each(["install", "update"] as const)( "%s names the CLI that found nothing to change", diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 97ceafd941f1e..884cd28c9df87 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -2,12 +2,14 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient } from "effect/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import * as ServiceProtection from "../cloud/serviceProtection.ts"; import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -68,7 +70,23 @@ function formatServiceChange( return `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${packageJson.version}.\nLogs: ${result.plan.logPath}`; } -function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: string): string { +function formatProtection( + protection: ServiceProtection.ServiceProtectionState | undefined, +): ReadonlyArray { + if (protection === undefined) return []; + return [ + protection.unprotected === undefined + ? ` Memory protection: on${protection.setByProtect ? " (t3 service protect)" : ""}` + : ` Memory protection: off, ${protection.unprotected} grants none. Run \`t3 service protect\`.`, + ...(protection.oomScoreAdj === undefined ? [] : [` OOM score: ${protection.oomScoreAdj}`]), + ]; +} + +function formatServiceStatus( + status: BootService.BootServiceStatus, + cliVersion: string, + protection?: ServiceProtection.ServiceProtectionState, +): string { if (!status.supported) { return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; } @@ -89,6 +107,7 @@ function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: ` Status: installed ยท t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...formatProtection(protection), ...problems, ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, ].join("\n"); @@ -98,6 +117,7 @@ function formatServiceStatus(status: BootService.BootServiceStatus, cliVersion: ` Status: ${status.current ? `installed ยท t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, + ...formatProtection(protection), ...problems, ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), ].join("\n"); @@ -210,12 +230,129 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( flags, Effect.gen(function* () { const service = yield* BootService.BootService; - yield* Console.log(formatServiceStatus(yield* service.status, packageJson.version)); + const path = yield* Path.Path; + const status = yield* service.status; + const protection = + status.installed && (yield* HostProcess.Platform) === "linux" + ? yield* ServiceProtection.readProtection(path.dirname(status.unitPath)).pipe( + Effect.provide(ProcessRunner.layer), + ) + : undefined; + yield* Console.log(formatServiceStatus(status, packageJson.version, protection)); }), ), ), ); +const formatDropIns = (dropIns: ReadonlyArray) => + dropIns.map( + ({ path, contents }) => ` ${path}: ${contents.trimEnd().split("\n").slice(1).join(" ")}`, + ); + +const confirm = (yes: boolean, message: string) => + yes ? Effect.succeed(true) : Prompt.run(Prompt.Confirm({ message, initial: false })); + +const serviceProtectCommand = Command.make("protect", { + ...projectLocationFlags, + undo: Flag.Boolean("undo").pipe( + Flag.withDescription("Remove the protection this command set up."), + Flag.withDefault(false), + ), + yes: Flag.Boolean("yes").pipe( + Flag.withDescription("Answer yes to every question, including SSH and Tailscale."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Protect the T3 Code server from agents under memory pressure (Linux with systemd). Asks for sudo.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const unsupported = "Service protection needs Linux with systemd."; + if ((yield* HostProcess.Platform) !== "linux") return yield* Console.log(unsupported); + // Before resolving config, which creates T3 directories that root would then own. + if ((yield* HostProcess.UserId) === 0) { + return yield* Console.log( + "Run `t3 service protect` as the user that runs T3 Code, without sudo. It asks for sudo when it needs root.", + ); + } + return yield* runServiceCommand( + flags, + Effect.gen(function* () { + const uid = yield* HostProcess.UserId; + const remote = yield* ServiceProtection.detectRemoteAccessUnits; + const { unitPath } = yield* (yield* BootService.BootService).status; + if (uid === undefined || remote === undefined || unitPath === "") { + return yield* Console.log(unsupported); + } + const userUnitDir = (yield* Path.Path).dirname(unitPath); + + if (flags.undo) { + const installed = yield* ServiceProtection.installedProtection( + ServiceProtection.protectionPlan({ + uid, + userUnitDir, + remoteAccessUnits: ServiceProtection.REMOTE_ACCESS_UNITS, + }), + ); + if (installed === undefined) { + return yield* Console.log("T3 Code service protection is not set up."); + } + yield* Console.log( + [ + "Removes:", + ...[...installed.system, ...installed.user, installed.managerDefaults].map( + ({ path, shared }) => + ` ${path}${shared === true ? " (kept while another user is protected)" : ""}`, + ), + ].join("\n"), + ); + if (!(yield* confirm(flags.yes, "Remove T3 Code service protection?"))) return; + yield* ServiceProtection.removeProtection(installed); + return yield* Console.log( + "Removed T3 Code service protection. The server's OOM score returns to normal after `t3 service restart`.", + ); + } + + yield* Console.log( + [ + "Keeps T3 Code running when agents fill this machine's memory:", + " - reserves memory for the T3 Code server, so the kernel takes memory from agents first", + " - makes the out-of-memory killer pick agents before the server", + "It adds systemd drop-in files and changes nothing else. Undo with `t3 service protect --undo`.", + ].join("\n"), + ); + const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and "); + const protectRemote = + remote.length > 0 && + (yield* confirm( + flags.yes, + `Also protect ${names} so you can still reach this machine under memory pressure?`, + )); + const plan = ServiceProtection.protectionPlan({ + uid, + userUnitDir, + remoteAccessUnits: protectRemote ? remote : [], + }); + yield* Console.log( + [ + "Drop-ins it writes, as root through sudo:", + ...formatDropIns(plan.system), + "As you:", + ...formatDropIns([plan.managerDefaults, ...plan.user]), + ].join("\n"), + ); + if (!(yield* confirm(flags.yes, "Apply these settings?"))) return; + yield* ServiceProtection.applyProtection(plan); + yield* Console.log( + "Protected T3 Code. Memory protection applies now; the server takes its OOM score after `t3 service restart`. Undo with `t3 service protect --undo`.", + ); + }).pipe(Effect.provide(ProcessRunner.layer)), + ); + }), + ), +); + export type ServiceOnboardingOutcome = "ready" | "pending" | "skipped"; export const offerServiceDuringOnboarding = Effect.gen(function* () { @@ -305,6 +442,7 @@ export const serviceCommand = Command.make("service").pipe( serviceRestartCommand, serviceUninstallCommand, serviceStatusCommand, + serviceProtectCommand, serviceUpdateCommand, ]), ); diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts index 244636c514061..3e94d0d9957f7 100644 --- a/apps/server/src/cli/trace.ts +++ b/apps/server/src/cli/trace.ts @@ -100,7 +100,7 @@ export function makeTraceSpanSummary(sinceMs = -Infinity) { } const formatMs = (ms: number) => - ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`; + ms < 1_000 ? `${Math.round(ms)}ms` : `${Math.round(ms / 100) / 10}s`; function formatTraceSummary( summary: ReturnType["finish"]>, @@ -127,7 +127,7 @@ function formatTraceSummary( const widths = header.map((_, column) => Math.max(...table.map((row) => row[column]!.length))); const formatIso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); return [ - `${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1)} min).`, + `${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1).replace(/\.0+$/, "")} min).`, ...(summary.skippedLineCount > 0 ? [`Skipped ${summary.skippedLineCount} lines that are not spans.`] : []), diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index fca51dcac1e09..2edd110648013 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -48,6 +48,25 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); +it("protects the server unit from agents under CPU and memory pressure", () => { + const unit = BootService.renderBootServiceUnit({ + program: ["/home/theo/.t3/runtime/versions/1.2.3/t3", "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + const service = unit.slice(unit.indexOf("[Service]"), unit.indexOf("[Install]")); + for (const line of [ + "CPUWeight=1000", + "IOWeight=1000", + "MemoryLow=1536M", + "OOMScoreAdjust=100", + "OOMPolicy=continue", + ]) { + expect(service.split("\n")).toContain(line); + } +}); + it.each([ ["a unit", '/home/theo/T3 "Data"/100%\\x', BootService.renderBootServiceUnit], [ diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 15b3b16a7bc0f..948d780c867cc 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -110,9 +110,15 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "OOMPolicy=continue", "Restart=always", "RestartSec=5", - // Weights favor the server over the sibling agents slice during CPU or disk saturation. + // Weights favor the server over the sibling agents slice; IOWeight needs a delegated io controller. "CPUWeight=1000", "IOWeight=1000", + // Reclaim takes the agents' unprotected pages first. The kernel caps this at the + // protection of user.slice and user@.service, which only root can grant. + "MemoryLow=1536M", + // A user manager's own score, the lowest it may set: below its other services (200) and agents (800). + // `t3 service protect` lowers it further through a drop-in. + "OOMScoreAdjust=100", // systemd-oomd ignores this preference when the watched cgroup is root-owned, as user-1000.slice usually is. "ManagedOOMPreference=avoid", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index f2ed1f982904f..89eb9a1a2889b 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -553,3 +553,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 b7a2a0e2ca780..8da7571e11056 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -23,11 +23,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, @@ -220,6 +221,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; @@ -246,12 +267,27 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { ? new ServerSelfUpdateError({ reason }) : new ServerSelfUpdateError({ reason, cause }); + const crossesTrackUnsafely = (input: ServerSelfUpdateInput) => + trackSwitchBlockReason({ + serverVersion: packageJson.version, + targetVersion: input.targetVersion, + targetDatabaseMigration: input.targetDatabaseMigration, + databaseMigration: () => databaseMigrationHead(serverConfig.dbPath), + }); + const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) { if (capability === "desktop-managed") { + const unsafeCrossing = input.channel === undefined ? null : crossesTrackUnsafely(input); + if (unsafeCrossing !== null) return yield* failWith(unsafeCrossing); if (desktopAppUpdate.available) { - return yield* desktopAppUpdate.run(reportProgress); + return yield* desktopAppUpdate.run( + reportProgress, + input.channel === undefined + ? undefined + : { channel: input.channel, targetVersion: input.targetVersion }, + ); } return yield* failWith( "This server is managed by the T3 Code desktop app on its machine; update the desktop app to update it.", @@ -263,6 +299,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/cloud/serviceProtection.test.ts b/apps/server/src/cloud/serviceProtection.test.ts new file mode 100644 index 0000000000000..eef78cd3f82e7 --- /dev/null +++ b/apps/server/src/cloud/serviceProtection.test.ts @@ -0,0 +1,270 @@ +// @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"; + +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 FileSystem from "effect/FileSystem"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import type * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as ServiceProtection from "./serviceProtection.ts"; + +const temporaryDirectory = () => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-service-protection-")); + +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("renderPrivilegedScript", () => { + it("writes and removes only its drop-ins and gives running main processes their score", () => { + const root = temporaryDirectory(); + const target = NodeChildProcess.spawn("sleep", ["60"], { stdio: "ignore" }); + try { + const systemUnitDir = NodePath.join(root, "system"); + const plan = (uid: number, remoteAccessUnits: ReadonlyArray) => + ServiceProtection.protectionPlan({ + uid, + userUnitDir: "/home/theo/.config/systemd/user", + remoteAccessUnits, + systemUnitDir, + }); + const theo = plan(1000, ["tailscaled.service", "ssh.service"]); + expect(theo.system.find((dropIn) => dropIn.unit === "user@1000.service")?.contents).toBe( + "[Service]\nMemoryLow=2G\nOOMScoreAdjust=-900\n", + ); + expect(theo.managerDefaults.path).toBe( + "/home/theo/.config/systemd/user.conf.d/90-t3code-protect.conf", + ); + expect(theo.oomUnits).toEqual(["user@1000.service", "tailscaled.service", "ssh.service"]); + + const unrelated = NodePath.join(systemUnitDir, "ssh.service.d", "50-admin.conf"); + NodeFS.mkdirSync(NodePath.dirname(unrelated), { recursive: true }); + NodeFS.writeFileSync(unrelated, "[Service]\nNice=1\n"); + const bin = NodePath.join(root, "bin"); + NodeFS.mkdirSync(bin); + // Raising a score needs no privilege, so the test's own child stands in for a main process. + NodeFS.writeFileSync( + NodePath.join(bin, "systemctl"), + [ + "#!/bin/sh", + 'echo "$*" >>"$CALLS"', + 'case "$*" in', + ' *MainPID*) echo "$TARGET_PID" ;;', + " *OOMScoreAdjust*) echo 900 ;;", + "esac", + "", + ].join("\n"), + { mode: 0o755 }, + ); + const calls = NodePath.join(root, "calls"); + const runScript = (protection: ServiceProtection.ProtectionPlan, mode: "apply" | "undo") => { + const result = NodeChildProcess.spawnSync( + "/bin/sh", + ["-c", ServiceProtection.renderPrivilegedScript(protection, mode)], + { + encoding: "utf8", + env: { + PATH: `${bin}:/usr/bin:/bin`, + CALLS: calls, + TARGET_PID: String(target.pid), + }, + }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + }; + const exists = (unit: string) => + NodeFS.existsSync(NodePath.join(systemUnitDir, `${unit}.d`, "90-t3code-protect.conf")); + + runScript(theo, "apply"); + runScript(plan(1001, []), "apply"); + for (const { path, contents } of theo.system) { + expect(NodeFS.readFileSync(path, "utf8")).toBe(contents); + } + expect(NodeFS.readFileSync(`/proc/${target.pid}/oom_score_adj`, "utf8").trim()).toBe("900"); + expect(NodeFS.readFileSync(calls, "utf8").split("\n")[0]).toBe("daemon-reload"); + + // Undo removes every drop-in a user could own, and keeps shared ones while another user is protected. + const undo = (uid: number) => plan(uid, ServiceProtection.REMOTE_ACCESS_UNITS); + runScript(undo(1000), "undo"); + expect(["user-1000.slice", "user@1000.service"].some(exists)).toBe(false); + expect( + ["user.slice", "system.slice", "tailscaled.service", "ssh.service"].every(exists), + ).toBe(true); + + runScript(undo(1001), "undo"); + for (const { path } of undo(1001).system) expect(NodeFS.existsSync(path)).toBe(false); + expect(NodeFS.existsSync(NodePath.join(systemUnitDir, "user.slice.d"))).toBe(false); + expect(NodeFS.readFileSync(unrelated, "utf8")).toBe("[Service]\nNice=1\n"); + } finally { + target.kill(); + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +it.each([ + [ + "Debian, where sshd.service aliases ssh.service", + "Id=tailscaled.service\nLoadState=loaded\n\nId=ssh.service\nLoadState=loaded\n\nId=ssh.service\nLoadState=loaded\n", + ["tailscaled.service", "ssh.service"], + ], + [ + "Fedora without Tailscale", + "Id=tailscaled.service\nLoadState=not-found\n\nId=ssh.service\nLoadState=not-found\n\nId=sshd.service\nLoadState=loaded\n", + ["sshd.service"], + ], +])("finds remote-access services on %s", (_, shown, expected) => { + expect(ServiceProtection.loadedRemoteAccessUnits(shown)).toEqual(expected); +}); + +const fakeSudo = (exitCode: number, onRun: (command: ChildProcess.StandardCommand) => void) => + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand" || command.command !== "sudo") { + return yield* Effect.die("Unexpected command"); + } + onRun(command); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + +const runner = (stdout: string) => + ProcessRunner.ProcessRunner.of({ + run: () => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + }); + +it.effect("lowers no score before the manager default is in place, and undoes in reverse", () => { + const home = temporaryDirectory(); + return Effect.gen(function* () { + const plan = ServiceProtection.protectionPlan({ + uid: 1000, + userUnitDir: NodePath.join(home, ".config", "systemd", "user"), + remoteAccessUnits: [], + systemUnitDir: NodePath.join(home, "system"), + }); + const managerDropIn = plan.system.find(({ unit }) => unit === "user@1000.service")!.path; + const present = () => ({ + managerDefaults: NodeFS.existsSync(plan.managerDefaults.path), + user: plan.user.filter(({ path }) => NodeFS.existsSync(path)).length, + }); + const withSudo = ( + effect: Effect.Effect, + exitCode: number, + writesManagerDropIn = false, + ) => { + let atSudo: ReturnType | undefined; + let sudoOptions: ChildProcess.CommandOptions | undefined; + return effect.pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + fakeSudo(exitCode, (command) => { + atSudo = present(); + sudoOptions = command.options; + if (writesManagerDropIn) { + NodeFS.mkdirSync(NodePath.dirname(managerDropIn), { recursive: true }); + NodeFS.writeFileSync(managerDropIn, "[Service]\nOOMScoreAdjust=-900\n"); + } + }), + ), + Effect.exit, + Effect.map((exit) => ({ exit, atSudo, sudoOptions })), + ); + }; + const refused = yield* withSudo(ServiceProtection.applyProtection(plan), 1); + expect(refused.exit._tag).toBe("Failure"); + expect(refused.atSudo).toEqual({ managerDefaults: true, user: 0 }); + expect(present()).toEqual({ managerDefaults: false, user: 0 }); + + // Failing after it lowered the manager, sudo must leave the manager default in place. + const partial = yield* withSudo(ServiceProtection.applyProtection(plan), 1, true); + expect(partial.exit._tag).toBe("Failure"); + expect(present()).toEqual({ managerDefaults: true, user: 0 }); + NodeFS.rmSync(plan.managerDefaults.path); + NodeFS.rmSync(managerDropIn); + + const applied = yield* withSudo(ServiceProtection.applyProtection(plan), 0); + expect(applied.atSudo).toEqual({ managerDefaults: true, user: 0 }); + // sudo needs the controlling terminal to ask for a password. + expect(applied.sudoOptions).toMatchObject({ detached: false, stdin: "inherit" }); + expect(present()).toEqual({ managerDefaults: true, user: plan.user.length }); + + const removed = yield* withSudo(ServiceProtection.removeProtection(plan), 0); + expect(removed.atSudo).toEqual({ managerDefaults: true, user: 0 }); + expect(present()).toEqual({ managerDefaults: false, user: 0 }); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(home, { recursive: true, force: true }))), + Effect.provideService(ProcessRunner.ProcessRunner, runner("")), + Effect.provide(NodeServices.layer), + ); +}); + +it.effect("counts memory.min as protection and names the first cgroup that grants none", () => { + const root = temporaryDirectory(); + const controlGroup = [ + "user.slice", + "user-1000.slice", + "user@1000.service", + "app.slice", + "t3code.service", + ]; + const write = (depth: number, file: string, value: string) => { + const directory = NodePath.join(root, ...controlGroup.slice(0, depth)); + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(directory, file), `${value}\n`); + }; + write(1, "memory.low", "0"); + write(1, "memory.min", "805306368"); + write(2, "memory.low", "2147483648"); + write(3, "memory.low", "2147483648"); + write(4, "memory.low", "0"); + write(4, "memory.min", "0"); + write(5, "memory.low", "1610612736"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const read = ServiceProtection.readProtection("/home/theo/.config/systemd/user").pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readFileString: (path, encoding) => + fs.readFileString(path.replace("/sys/fs/cgroup", root), encoding), + }), + ); + expect((yield* read)?.unprotected).toBe("app.slice"); + write(4, "memory.low", "1610612736"); + expect((yield* read)?.unprotected).toBeUndefined(); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true }))), + Effect.provideService( + ProcessRunner.ProcessRunner, + runner(`ControlGroup=/${controlGroup.join("/")}\nMainPID=0\n`), + ), + Effect.provide(NodeServices.layer), + ); +}); diff --git a/apps/server/src/cloud/serviceProtection.ts b/apps/server/src/cloud/serviceProtection.ts new file mode 100644 index 0000000000000..780aee83b4c48 --- /dev/null +++ b/apps/server/src/cloud/serviceProtection.ts @@ -0,0 +1,345 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { BOOT_SERVICE_UNIT_FILE } from "./bootService.ts"; + +const PROTECT_DROP_IN = "90-t3code-protect.conf"; +const SYSTEM_UNIT_DIR = "/etc/systemd/system"; +export const REMOTE_ACCESS_UNITS = ["tailscaled.service", "ssh.service", "sshd.service"]; + +// Covers app.slice (the server's 1536M) and init.scope (the user manager's 256M). +const USER_CHAIN_LOW = "MemoryLow=2G"; +// Near the floor, but unlike -1000 the kernel can still kill it as a last resort. +const PROTECTED_OOM_SCORE = "OOMScoreAdjust=-900"; + +export interface DropIn { + readonly unit: string; + readonly path: string; + readonly contents: string; + /** Host-wide, so undo keeps it while another user's protection remains. */ + readonly shared?: boolean; +} + +export interface ProtectionPlan { + readonly systemUnitDir: string; + /** Written through sudo. */ + readonly system: ReadonlyArray; + /** + * Written before the manager's own score drops. Unset, a user manager hands + * its services its own score plus 100: -800 once it runs at -900. + */ + readonly managerDefaults: DropIn; + readonly user: ReadonlyArray; + /** System units whose running main process takes its configured OOM score at once. */ + readonly oomUnits: ReadonlyArray; +} + +const dropIn = ( + directory: string, + unit: string, + section: string, + properties: ReadonlyArray, + shared = false, +): DropIn => ({ + unit, + path: `${directory}/${unit}.d/${PROTECT_DROP_IN}`, + contents: [`[${section}]`, ...properties, ""].join("\n"), + ...(shared ? { shared } : {}), +}); + +/** + * Memory protection only reaches the server when every ancestor cgroup grants + * it, and only root can grant it on user.slice and the user manager. + */ +export function protectionPlan(input: { + readonly uid: number; + readonly userUnitDir: string; + readonly remoteAccessUnits: ReadonlyArray; + readonly systemUnitDir?: string; +}): ProtectionPlan { + const manager = `user@${input.uid}.service`; + const remote = input.remoteAccessUnits; + const systemUnitDir = input.systemUnitDir ?? SYSTEM_UNIT_DIR; + const system = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(systemUnitDir, unit, section, properties); + const shared = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(systemUnitDir, unit, section, properties, true); + const user = (unit: string, section: string, properties: ReadonlyArray) => + dropIn(input.userUnitDir, unit, section, properties); + return { + systemUnitDir, + system: [ + shared("user.slice", "Slice", [USER_CHAIN_LOW]), + system(`user-${input.uid}.slice`, "Slice", [USER_CHAIN_LOW]), + system(manager, "Service", [USER_CHAIN_LOW, PROTECTED_OOM_SCORE]), + ...(remote.length > 0 ? [shared("system.slice", "Slice", ["MemoryLow=512M"])] : []), + ...remote.map((unit) => shared(unit, "Service", ["MemoryLow=256M", PROTECTED_OOM_SCORE])), + ], + managerDefaults: { + unit: "user.conf", + path: `${input.userUnitDir.replace(/\/user$/, "")}/user.conf.d/${PROTECT_DROP_IN}`, + contents: "[Manager]\nDefaultOOMScoreAdjust=200\n", + }, + user: [ + user("app.slice", "Slice", ["MemoryLow=1536M"]), + user("init.scope", "Scope", ["MemoryLow=256M"]), + user(BOOT_SERVICE_UNIT_FILE, "Service", [PROTECTED_OOM_SCORE]), + ], + oomUnits: [manager, ...remote], + }; +} + +const quoteShell = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; +const directoryOf = (file: string) => file.slice(0, file.lastIndexOf("/")); + +export function renderPrivilegedScript(plan: ProtectionPlan, mode: "apply" | "undo"): string { + const lines = ["set -e"]; + const remove = (dropIns: ReadonlyArray, indent = "") => { + for (const { path } of dropIns) { + lines.push( + `${indent}rm -f ${quoteShell(path)}`, + `${indent}rmdir ${quoteShell(directoryOf(path))} 2>/dev/null || true`, + ); + } + }; + if (mode === "apply") { + for (const { path, contents } of plan.system) { + lines.push( + `mkdir -p ${quoteShell(directoryOf(path))}`, + `cat >${quoteShell(path)} <<'T3CODE_PROTECT'`, + contents.trimEnd(), + "T3CODE_PROTECT", + ); + } + } else { + remove(plan.system.filter((dropIn) => dropIn.shared !== true)); + const otherUsers = `${quoteShell(plan.systemUnitDir)}/user@*.service.d/${PROTECT_DROP_IN}`; + lines.push(`set -- ${otherUsers}`, 'if [ ! -e "$1" ]; then'); + remove( + plan.system.filter((dropIn) => dropIn.shared === true), + " ", + ); + lines.push("fi"); + } + lines.push( + "systemctl daemon-reload", + // OOMScoreAdjust applies at exec; this gives running main processes the configured score. + `for unit in ${plan.oomUnits.map(quoteShell).join(" ")}; do`, + ' pid=$(systemctl show --property=MainPID --value "$unit")', + ' if [ "${pid:-0}" -gt 0 ]; then', + ' systemctl show --property=OOMScoreAdjust --value "$unit" >"/proc/$pid/oom_score_adj" || true', + " fi", + "done", + "", + ); + return lines.join("\n"); +} + +export class ServiceProtectionError extends Schema.TaggedError()( + "ServiceProtectionError", + { + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.exitCode === undefined + ? `Could not ${this.step}.` + : `Could not ${this.step} (exit code ${this.exitCode}).`; + } +} + +function parseUnits(text: string): Array> { + return text + .split(/\n\s*\n/) + .map( + (block) => + new Map( + block + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), + ), + ) + .filter((values) => values.size > 0); +} + +/** Loaded remote-access units by canonical name, so an alias such as sshd.service counts once. */ +export function loadedRemoteAccessUnits(systemctlShow: string): ReadonlyArray { + const units = parseUnits(systemctlShow) + .filter((values) => values.get("LoadState") === "loaded") + .map((values) => values.get("Id") ?? "") + .filter((id) => REMOTE_ACCESS_UNITS.includes(id)); + return [...new Set(units)]; +} + +const run = Effect.fn("ServiceProtection.run")(function* ( + step: string, + command: string, + args: ReadonlyArray, +) { + const runner = yield* ProcessRunner.ProcessRunner; + const result = yield* runner + .run({ command, args, timeout: "30 seconds" }) + .pipe(Effect.mapError((cause) => new ServiceProtectionError({ step, cause }))); + if (result.code !== 0) { + return yield* new ServiceProtectionError({ step, exitCode: Number(result.code) }); + } + return result.stdout; +}); + +const userSettingsError = (cause: unknown) => + new ServiceProtectionError({ step: "change systemd user settings", cause }); + +/** Undefined when there is no systemd system manager to ask. */ +export const detectRemoteAccessUnits = run("list remote-access services", "systemctl", [ + "show", + "--property=Id,LoadState", + ...REMOTE_ACCESS_UNITS, +]).pipe( + Effect.map(loadedRemoteAccessUnits), + Effect.option, + Effect.map(Option.getOrUndefined), + Effect.withSpan("ServiceProtection.detectRemoteAccessUnits"), +); + +const reloadUserManager = run("reload systemd user units", "systemctl", [ + "--user", + "daemon-reload", +]); + +const writeDropIns = Effect.fn("ServiceProtection.writeDropIns")(function* ( + dropIns: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + for (const { path, contents } of dropIns) { + yield* fs.makeDirectory(directoryOf(path), { recursive: true }); + yield* fs.writeFileString(path, contents); + } +}, Effect.mapError(userSettingsError)); + +const removeDropIns = Effect.fn("ServiceProtection.removeDropIns")(function* ( + dropIns: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + for (const { path } of dropIns) { + yield* fs.remove(path, { force: true }); + yield* fs.remove(directoryOf(path)).pipe(Effect.ignore); + } +}, Effect.mapError(userSettingsError)); + +const runPrivileged = Effect.fn("ServiceProtection.runPrivileged")(function* (script: string) { + const step = "change the system's service settings"; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner + .exitCode( + // Effect detaches children on POSIX by default; sudo then has no controlling + // terminal and cannot ask for a password. + ChildProcess.make("sudo", ["/bin/sh", "-c", script], { + detached: false, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }), + ) + .pipe(Effect.mapError((cause) => new ServiceProtectionError({ step, cause }))); + if (exitCode !== 0) { + return yield* new ServiceProtectionError({ step, exitCode: Number(exitCode) }); + } +}); + +export const applyProtection = Effect.fn("ServiceProtection.apply")(function* ( + plan: ProtectionPlan, +) { + const fs = yield* FileSystem.FileSystem; + const hadManagerDefaults = yield* fs + .exists(plan.managerDefaults.path) + .pipe(Effect.orElseSucceed(() => false)); + yield* writeDropIns([plan.managerDefaults]); + // A partial run may already have lowered the manager; its default must then stay. + const manager = plan.system.find(({ unit }) => unit.startsWith("user@")); + const rollBack = Effect.gen(function* () { + if (hadManagerDefaults) return; + if (manager !== undefined && (yield* fs.exists(manager.path))) return; + yield* removeDropIns([plan.managerDefaults]); + }).pipe(Effect.ignore); + yield* runPrivileged(renderPrivilegedScript(plan, "apply")).pipe(Effect.tapError(() => rollBack)); + yield* writeDropIns(plan.user); + yield* reloadUserManager; +}); + +export const removeProtection = Effect.fn("ServiceProtection.remove")(function* ( + plan: ProtectionPlan, +) { + yield* removeDropIns(plan.user); + yield* reloadUserManager; + if (plan.system.length > 0) yield* runPrivileged(renderPrivilegedScript(plan, "undo")); + yield* removeDropIns([plan.managerDefaults]); + yield* reloadUserManager; +}); + +/** The plan narrowed to drop-ins that exist, for undo. */ +export const installedProtection = Effect.fn("ServiceProtection.installed")(function* ( + plan: ProtectionPlan, +) { + const fs = yield* FileSystem.FileSystem; + const exists = (dropIn: DropIn) => fs.exists(dropIn.path).pipe(Effect.orElseSucceed(() => false)); + const system = yield* Effect.filter(plan.system, exists); + const user = yield* Effect.filter(plan.user, exists); + const managerDefaults = yield* exists(plan.managerDefaults); + return system.length + user.length === 0 && !managerDefaults + ? undefined + : { ...plan, system, user }; +}); + +export interface ServiceProtectionState { + /** The first cgroup from the top that grants the server no memory protection. */ + readonly unprotected: string | undefined; + readonly oomScoreAdj: number | undefined; + readonly setByProtect: boolean; +} + +/** Reads what the kernel applies to the running service, whoever configured it. */ +export const readProtection = Effect.fn("ServiceProtection.read")(function* (userUnitDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const shown = yield* run("read the service's cgroup", "systemctl", [ + "--user", + "show", + BOOT_SERVICE_UNIT_FILE, + "--property=ControlGroup,MainPID", + ]).pipe(Effect.option); + if (Option.isNone(shown)) return undefined; + const values = parseUnits(shown.value)[0] ?? new Map(); + const controlGroup = (values.get("ControlGroup") ?? "").split("/").filter(Boolean); + if (controlGroup.length === 0) return undefined; + const read = (file: string) => + fs.readFileString(file).pipe( + Effect.map((text) => text.trim()), + Effect.orElseSucceed(() => ""), + ); + let unprotected: string | undefined; + const grants = (value: string) => value !== "" && value !== "0"; + for (let depth = 1; depth <= controlGroup.length && unprotected === undefined; depth++) { + const directory = path.join("/sys/fs/cgroup", ...controlGroup.slice(0, depth)); + const low = yield* read(path.join(directory, "memory.low")); + const min = yield* read(path.join(directory, "memory.min")); + if (!grants(low) && !grants(min)) unprotected = controlGroup[depth - 1]; + } + const pid = Number(values.get("MainPID") ?? 0); + const score = pid > 0 ? yield* read(`/proc/${pid}/oom_score_adj`) : ""; + return { + unprotected, + oomScoreAdj: score === "" ? undefined : Number(score), + setByProtect: yield* fs + .exists(path.join(userUnitDir, `${BOOT_SERVICE_UNIT_FILE}.d`, PROTECT_DROP_IN)) + .pipe(Effect.orElseSucceed(() => false)), + } satisfies ServiceProtectionState; +}); 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 d02c3108777dd..e901b04cfb5d7 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -44,6 +44,7 @@ export class DesktopAppUpdate extends Context.Service< reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch?: DesktopTelemetryReceiver.DesktopTrackSwitch, ) => Effect.Effect; /** Success stops the server; returns only on installation failure or timeout. */ readonly commit: ( @@ -71,6 +72,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( reportProgress: ( stage: ServerSelfUpdateProgressStage, ) => Effect.Effect, + trackSwitch: boolean, ) { const lastStage = yield* Ref.make(null); const emitStage = ( @@ -122,6 +124,11 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; } if (report.outcome === "up-to-date") { + // A switch's host already on the newest build of its track only saved the track. + if (trackSwitch) { + yield* Ref.set(inFlight, false); + return { targetVersion: report.state.currentVersion, method: "desktop-app" as const }; + } return yield* failWith( `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, ); @@ -132,7 +139,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( }); const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( - function* (reportProgress) { + function* (reportProgress, trackSwitch) { if (!available) { return yield* failWith( "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", @@ -154,13 +161,18 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( // Subscribe before requesting to catch fast reports. const { changes } = yield* receiver.desktopUpdates; yield* receiver - .requestDesktopUpdate(requestId) + .requestDesktopUpdate(requestId, trackSwitch) .pipe( Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app on this machine.", error), ), ); - return yield* consumeReports(requestId, changes, reportProgress).pipe( + return yield* consumeReports( + requestId, + changes, + reportProgress, + trackSwitch !== undefined, + ).pipe( Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)), ); }), diff --git a/apps/server/src/device/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/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 07b45737f6730..72c8f9e0e5d56 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -169,6 +169,32 @@ describe("device hub proxy", () => { expect(headers[0]?.["content-type"]).toBe("application/json"); }); + it("lets a viewer read Android stream capabilities and signal WebRTC with read scope", async () => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const hub = "http://t3.test/api/device-hub/vendor/serve-emu"; + const api = await handler(new Request(`${hub}/api?device=session-1`)); + expect(api.status).toBe(200); + await api.text(); + for (const action of ["offer", "close"]) { + const signal = await handler( + new Request(`${hub}/webrtc/${action}?device=session-1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"sessionId":"viewer"}', + }), + ); + expect(signal.status).toBe(200); + await signal.text(); + } + expect(requests).toEqual([ + "http://hub.test/vendor/serve-emu/api?device=session-1", + "http://hub.test/vendor/serve-emu/webrtc/offer?device=session-1", + "http://hub.test/vendor/serve-emu/webrtc/close?device=session-1", + ]); + expect((await handler(new Request(`${hub}/api`, { method: "POST" }))).status).toBe(405); + expect((await handler(new Request(`${hub}/webrtc/offer`))).status).toBe(405); + }); + it("sends requests a host routes itself to its upstream with its credentials", async () => { const { handler, requests, headers } = fixture( [AuthOrchestrationReadScope], diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 89cad0d7206e2..c1aaab3c61219 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -27,14 +27,17 @@ const ALLOWED_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/, /^\/vendor\/serve-sim\/appstate$/, + /^\/vendor\/serve-emu\/api$/, /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/, /^\/vendor\/serve-emu\/health$/, ]; +// Paths that take methods besides GET; one missing from ALLOWED_PATHS takes no GET. const MUTABLE_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, // WebRTC signalling: opening and closing a view of the stream, so read scope. /^\/vendor\/serve-sim\/helper\/[^/]+\/webrtc\/(offer|close)$/, + /^\/vendor\/serve-emu\/webrtc\/(offer|close)$/, /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/, /^\/vendor\/serve-emu\/api\/fold$/, ]; @@ -178,14 +181,15 @@ const handler = Effect.gen(function* () { } const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/"; const upgrade = isWebSocketUpgrade(request); - const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) => - pattern.test(hubPath), - ); + const matches = (paths: ReadonlyArray) => paths.some((pattern) => pattern.test(hubPath)); + const allowed = upgrade + ? matches(ALLOWED_WS_PATHS) + : matches(ALLOWED_PATHS) || matches(MUTABLE_PATHS); if (!allowed) { return HttpServerResponse.text("Not Found", { status: 404 }); } const readOnly = request.method === "GET" || request.method === "HEAD"; - if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) { + if (!upgrade && !matches(readOnly ? ALLOWED_PATHS : MUTABLE_PATHS)) { return HttpServerResponse.text("Method Not Allowed", { status: 405 }); } const controlsDevice = diff --git a/apps/server/src/device/EasDeviceHost.test.ts b/apps/server/src/device/EasDeviceHost.test.ts index 5802956e699b7..62a6a0c247a45 100644 --- a/apps/server/src/device/EasDeviceHost.test.ts +++ b/apps/server/src/device/EasDeviceHost.test.ts @@ -62,6 +62,7 @@ const fakeExpo = ( const calls: Array<{ operation: string; variables: Record }> = []; let created = 0; let createdName = ""; + const previews: string[] = []; let previewFails = options.failPreview ?? false; let createFails = options.failCreate ?? false; let sessionGated = false; @@ -69,6 +70,7 @@ const fakeExpo = ( Effect.gen(function* () { const url = new URL(request.url); if (url.origin === "https://preview.test") { + previews.push(`${url.pathname}${url.search}`); expect(request.headers.authorization).toBe("Bearer preview-token"); if (options.previewGate) { yield* Deferred.succeed(options.previewGate.requested, undefined); @@ -77,7 +79,17 @@ const fakeExpo = ( if (previewFails) { return HttpClientResponse.fromWeb(request, new Response("bad gateway", { status: 502 })); } - return HttpClientResponse.fromWeb(request, Response.json({ device: "UDID-1" })); + if (url.pathname.endsWith("/api/screenshot")) { + return HttpClientResponse.fromWeb(request, new Response(Uint8Array.of(137, 80, 78, 71))); + } + return HttpClientResponse.fromWeb( + request, + Response.json( + url.pathname === "/vendor/serve-emu/api" + ? { serial: "emulator-5554" } + : { device: "UDID-1" }, + ), + ); } const body = request.body._tag === "Uint8Array" @@ -195,7 +207,7 @@ const fakeExpo = ( const allowCreate = () => { createFails = false; }; - return { http, named, stopped, allowPreview, allowCreate }; + return { http, named, stopped, allowPreview, allowCreate, previews }; }; const layer = Layer.mergeAll( @@ -221,7 +233,10 @@ describe("EAS Simulator host", () => { const host = yield* makeHost(expo, config(), scope); const ready = yield* host.ensureReady(() => Effect.void); const catalog = (yield* ready.devices.list).devices; - expect(catalog.map((device) => [device.id, device.booted])).toEqual([["eas-ios", false]]); + expect(catalog.map((device) => [device.id, device.booted])).toEqual([ + ["eas-ios", false], + ["eas-android", false], + ]); expect(yield* ready.devices.boot(catalog[0]!)).toBe("session-1"); expect(expo.named("T3CreateEasSimulatorSession")[0]?.variables.input).toMatchObject({ @@ -266,6 +281,55 @@ describe("EAS Simulator host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("runs an Android session through serve-emu beside an iOS one", () => + Effect.gen(function* () { + const expo = fakeExpo(); + const scope = yield* Scope.make(); + const host = yield* makeHost(expo, config(), scope); + expect(yield* host.platformAvailability("android")).toEqual({ + platform: "android", + available: true, + }); + const ready = yield* host.ensureReady(() => Effect.void); + const [ios, android] = (yield* ready.devices.list).devices; + expect(yield* ready.devices.boot(ios!)).toBe("session-1"); + expect(yield* ready.devices.boot(android!)).toBe("session-2"); + expect(expo.named("T3CreateEasSimulatorSession")[1]?.variables.input).toMatchObject({ + platform: "ANDROID", + }); + const running = (yield* ready.devices.list).devices.find( + (device) => device.id === "session-2", + ); + expect(running).toMatchObject({ platform: "android", booted: true }); + + expect(yield* ready.devices.agentEndpoint!("session-2")).toMatchObject({ + agentDeviceId: "emulator-5554", + }); + expect(Array.from(yield* ready.devices.screenshot(running!))).toEqual([137, 80, 78, 71]); + expect(expo.previews.slice(-2)).toEqual([ + "/vendor/serve-emu/api", + "/vendor/serve-emu/api/screenshot?device=emulator-5554", + ]); + expect( + yield* ready.upstream!( + "/vendor/serve-emu/webrtc/offer", + new URLSearchParams("device=session-2&hostId=eas"), + ), + ).toEqual({ + url: "https://preview.test/vendor/serve-emu/webrtc/offer?device=emulator-5554&hostId=eas", + headers: { authorization: "Bearer preview-token" }, + protocols: ["serve-emu.token.preview-token"], + }); + // Each session answers only on its own platform's stream. + expect( + yield* ready.upstream!("/vendor/serve-emu/api", new URLSearchParams("device=session-1")), + ).toBeNull(); + expect( + yield* ready.upstream!("/vendor/serve-sim/api", new URLSearchParams("device=session-2")), + ).toBeNull(); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("refuses to start past its session limit", () => Effect.gen(function* () { const expo = fakeExpo(); @@ -292,6 +356,7 @@ describe("EAS Simulator host", () => { expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2"); expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ "eas-ios", + "eas-android", "session-1", "session-2", ]); @@ -306,6 +371,7 @@ describe("EAS Simulator host", () => { expect(expo.stopped()).toEqual(["session-1"]); expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ "eas-ios", + "eas-android", "session-2", ]); yield* Scope.close(scope, Exit.void); @@ -421,7 +487,10 @@ describe("EAS Simulator host", () => { expect(refused.message).toContain("limited to 1 running session"); stopFails = false; yield* ready.devices.list; - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); expect(expo.stopped().filter((id) => id === "session-1").length).toBeGreaterThanOrEqual(2); }).pipe(Effect.scoped, Effect.provide(layer)), ); @@ -438,7 +507,10 @@ describe("EAS Simulator host", () => { expect((yield* ready.devices.list).devices.map((device) => device.id)).toContain("session-1"); // Expo ended it by itself: the stop keeps failing, but the status says so. status = "STOPPED"; - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); status = "IN_PROGRESS"; expect(yield* ready.devices.boot(catalogDevice!)).toBe("session-2"); }).pipe(Effect.scoped, Effect.provide(layer)), @@ -488,7 +560,10 @@ describe("EAS Simulator host", () => { expect(expo.stopped()).toEqual(["session-1"]); const failure = yield* Fiber.join(booting).pipe(Effect.flip); expect(failure.message).toContain("was stopped while the session started"); - expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual(["eas-ios"]); + expect((yield* ready.devices.list).devices.map((device) => device.id)).toEqual([ + "eas-ios", + "eas-android", + ]); }).pipe(Effect.scoped, Effect.provide(layer)), ); }); diff --git a/apps/server/src/device/EasDeviceHost.ts b/apps/server/src/device/EasDeviceHost.ts index da8a665d4b4ed..a1137fe5156a0 100644 --- a/apps/server/src/device/EasDeviceHost.ts +++ b/apps/server/src/device/EasDeviceHost.ts @@ -42,14 +42,48 @@ const STATUS_TTL_MS = 15_000; // Expo's own CLI waits this long once a worker picks the session up. const START_TIMEOUT = Duration.minutes(15); const PREVIEW_TIMEOUT = Duration.seconds(20); -const SERVE_SIM_PREFIX = "/vendor/serve-sim"; +const decodeServeSimApi = Schema.decodeUnknownEffect( + Schema.Struct({ device: Schema.NonEmptyString }), +); +const decodeServeEmuApi = Schema.decodeUnknownEffect( + Schema.Struct({ serial: Schema.NonEmptyString }), +); +/** + * Each session's preview host serves serve-sim at its root and serve-emu under + * its vendor prefix, and names its device by an id of its own that agent-device + * also selects it by. + */ +const PLATFORMS = { + ios: { + name: "iPhone (EAS Simulator)", + hubPrefix: "/vendor/serve-sim", + previewPrefix: "", + protocol: "serve-sim", + remoteDeviceId: (api: unknown) => decodeServeSimApi(api).pipe(Effect.map((api) => api.device)), + }, + android: { + name: "Android (EAS Simulator)", + hubPrefix: "/vendor/serve-emu", + previewPrefix: "/vendor/serve-emu", + protocol: "serve-emu", + remoteDeviceId: (api: unknown) => decodeServeEmuApi(api).pipe(Effect.map((api) => api.serial)), + }, +} as const; /** EAS picks the simulator model; one entry starts a session. */ const CATALOG: ReadonlyArray = [ { id: "eas-ios", platform: "ios", - name: "iPhone (EAS Simulator)", + name: PLATFORMS.ios.name, + version: "Cloud", + booted: false, + physical: false, + }, + { + id: "eas-android", + platform: "android", + name: PLATFORMS.android.name, version: "Cloud", booted: false, physical: false, @@ -59,7 +93,8 @@ const CATALOG: ReadonlyArray = [ interface RunningSession { readonly sessionId: string; readonly name: string; - readonly udid: string; + readonly platform: DevicePlatform; + readonly remoteDeviceId: string; readonly preview: { readonly origin: string; readonly token: Redacted.Redacted }; readonly agent: DeviceHost.AgentDaemonEndpoint; /** Set once a stop has been asked for but Expo has not confirmed it. */ @@ -94,7 +129,6 @@ const RECORD_MAX_AGE_MS = 24 * 60 * 60_000; const UNCERTAIN_CREATE_MS = 5 * 60_000; // One failed request to Expo during a minutes-long start does not end the start. const TRANSIENT_RETRY = { times: 3, schedule: Schedule.spaced("2 seconds") } as const; -const decodePreviewApi = Schema.decodeUnknownEffect(Schema.Struct({ device: Schema.String })); /** A malformed escape names no device, so the proxy answers 404 instead of failing. */ const decodePathSegment = (segment: string): string | null => { @@ -116,7 +150,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice const hostId = config.id; const ledgerPath = path.join(server.stateDir, "device", "eas", `${hostId}.json`); // Keyed by Expo's session id, which is also the device id: every EAS - // simulator reports the same UDID, so the UDID cannot tell sessions apart. + // simulator reports the same remote device id, so that id cannot tell sessions apart. const sessions = new Map(); const checkedAt = new Map(); const startLock = yield* Semaphore.make(1); @@ -348,18 +382,21 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), ); - const previewDevice = (origin: string, token: Redacted.Redacted) => + const previewDevice = ( + origin: string, + token: Redacted.Redacted, + platform: DevicePlatform, + ) => http .execute( - HttpClientRequest.get(`${origin}/api`).pipe( + HttpClientRequest.get(`${origin}${PLATFORMS[platform].previewPrefix}/api`).pipe( HttpClientRequest.bearerToken(Redacted.value(token)), ), ) .pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap((response) => response.json), - Effect.flatMap(decodePreviewApi), - Effect.map((api) => api.device), + Effect.flatMap(PLATFORMS[platform].remoteDeviceId), Effect.scoped, Effect.timeout(PREVIEW_TIMEOUT), Effect.retry(TRANSIENT_RETRY), @@ -500,7 +537,9 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ), Effect.mapError((error) => unavailable(error.message, error)), ); - return yield* restore(trackSession(expo, sessionId, name, startedIn)).pipe( + return yield* restore( + trackSession(expo, sessionId, name, device.platform, startedIn), + ).pipe( Effect.onError(() => stopSession(expo, sessionId, name).pipe( // Still running and billed: owned, counted, and retried by listing. @@ -520,6 +559,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice expo: ExpoApi, sessionId: string, name: string, + platform: DevicePlatform, startedIn: number, ) { const endpoints = yield* awaitRunning(expo, sessionId); @@ -532,7 +572,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice }); } const origin = new URL(endpoints.preview.url).origin; - const udid = yield* previewDevice(origin, endpoints.preview.token).pipe( + const remoteDeviceId = yield* previewDevice(origin, endpoints.preview.token, platform).pipe( Effect.mapError( (cause) => new DeviceBootError({ @@ -549,7 +589,8 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice sessions.set(sessionId, { sessionId, name, - udid, + platform, + remoteDeviceId, preview: { origin, token: endpoints.preview.token }, agent: { baseUrl: endpoints.agentDevice.url, @@ -650,8 +691,8 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice ...CATALOG, ...[...sessions.values()].map((session): DeviceHost.HostDevice => ({ id: session.sessionId, - platform: "ios", - name: "iPhone (EAS Simulator)", + platform: session.platform, + name: PLATFORMS[session.platform].name, version: "Cloud", booted: true, physical: false, @@ -708,7 +749,7 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice http .execute( HttpClientRequest.post( - `${session.preview.origin}/api/screenshot?device=${encodeURIComponent(session.udid)}`, + `${session.preview.origin}${PLATFORMS[session.platform].previewPrefix}/api/screenshot?device=${encodeURIComponent(session.remoteDeviceId)}`, ).pipe(HttpClientRequest.bearerToken(Redacted.value(session.preview.token))), ) .pipe( @@ -730,42 +771,45 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice agentEndpoint: (deviceId) => Option.match(sessionFor(deviceId), { onNone: () => Effect.fail(unavailable("This EAS Simulator device is no longer running.")), - // agent-device selects the simulator by its UDID, not the session id T3 shows. - onSome: (session) => Effect.succeed({ ...session.agent, agentDeviceId: session.udid }), + onSome: (session) => + Effect.succeed({ ...session.agent, agentDeviceId: session.remoteDeviceId }), }), endsDevices: true, }; /** - * serve-sim runs at the root of each session's preview host and wants its - * token on every request. Clients name the device by its session id; serve-sim - * knows it by its UDID, so the id is swapped for the UDID in the path and in - * the `device` parameter. + * Clients name the device by its session id; the preview host knows it by its + * own id, so the session id is swapped for that in the path and in the + * `device` parameter. A session answers only on its own platform's server. */ const upstream: NonNullable = (hubPath, search) => Effect.sync(() => { - if (!hubPath.startsWith(`${SERVE_SIM_PREFIX}/`)) return null; + const platform = (["ios", "android"] as const).find((candidate) => + hubPath.startsWith(`${PLATFORMS[candidate].hubPrefix}/`), + ); + if (!platform) return null; const helper = /^\/vendor\/serve-sim\/helper\/([^/]+)\//.exec(hubPath); const deviceId = helper ? decodePathSegment(helper[1]!) : search.get("device"); if (helper && deviceId === null) return null; const session = - deviceId !== null && deviceId !== undefined + deviceId !== null ? sessions.get(deviceId) : sessions.size === 1 ? [...sessions.values()][0] : undefined; - if (!session) return null; + if (session?.platform !== platform) return null; + const { hubPrefix, previewPrefix, protocol } = PLATFORMS[platform]; const path = helper - ? `/helper/${encodeURIComponent(session.udid)}${hubPath.slice(helper[0].length - 1)}` - : hubPath.slice(SERVE_SIM_PREFIX.length); + ? `/helper/${encodeURIComponent(session.remoteDeviceId)}${hubPath.slice(helper[0].length - 1)}` + : `${previewPrefix}${hubPath.slice(hubPrefix.length)}`; const params = new URLSearchParams(search); - if (params.has("device")) params.set("device", session.udid); + if (params.has("device")) params.set("device", session.remoteDeviceId); const query = params.size > 0 ? `?${params.toString()}` : ""; const token = Redacted.value(session.preview.token); return { url: `${session.preview.origin}${path}${query}`, headers: { authorization: `Bearer ${token}` }, - protocols: [`serve-sim.token.${token}`], + protocols: [`${protocol}.token.${token}`], }; }); @@ -833,19 +877,13 @@ export const make = Effect.fn("EasDeviceHost.make")(function* (config: EasDevice // Each server signs in on its own, so one with no Expo credential answers for itself. Reading // the credential is local; whether Expo accepts it is only known once a session starts. const platformAvailability = (platform: DevicePlatform) => - platform === "ios" - ? resolveExpoCredential(config.accessToken).pipe( - Effect.provide(services), - Effect.as({ platform, available: true }), - Effect.catch((error) => - Effect.succeed({ platform, available: false, reason: error.message }), - ), - ) - : Effect.succeed({ - platform, - available: false, - reason: "EAS Simulator Android emulators are not supported yet.", - }); + resolveExpoCredential(config.accessToken).pipe( + Effect.provide(services), + Effect.as({ platform, available: true }), + Effect.catch((error) => + Effect.succeed({ platform, available: false, reason: error.message }), + ), + ); const summary: Effect.Effect = Effect.all([ platformAvailability("ios"), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c68a5a092b984..210c690da4a5d 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, @@ -241,6 +244,7 @@ export const make = Effect.gen(function* () { projectSettingsOverrides: true, projectProviderInstanceOverrides: true, threadSnooze: true, + threadForceStop: true, environmentThemes: true, usageLimitSources: true, providerWorkspaceSnapshotsOnDemand: true, @@ -277,7 +281,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 ba77ffb441041..a42aed8994d5c 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2613,7 +2613,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); @@ -2700,6 +2738,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { cwd: repoDir, action: "commit", threadId, + projectId: ProjectId.make("project:other"), }); expect(generatedModelSelection).toEqual(projectModel); @@ -3920,7 +3959,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* () { @@ -3970,7 +4009,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", @@ -4007,9 +4046,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 }, @@ -4022,15 +4061,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 53d0b4f5853ff..71e5edb3b697b 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 4960a2983c11a..a9b13203355ee 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -379,12 +379,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 1ef25744c8c55..ac5ffc76aae77 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -7,6 +7,7 @@ import { GuestShareId, type GuestStreamEvent, MessageId, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2TurnItem, ProjectId, ProviderDriverKind, @@ -529,6 +530,90 @@ it.effect("gives the guest a command's detail and output, but no file diffs", () }).pipe(Effect.provide(layerTest)), ); +it.effect("pages the guest back through turns older than the stream's snapshot", () => + Effect.gen(function* () { + const { guestShares, credential } = yield* shareThread; + const { token: session } = yield* guestShares.redeem({ credential, name: "Sam" }); + const runId = yield* ownerRun; + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const shot = `${directory}/old.png`; + yield* fileSystem.writeFile(shot, PNG); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const base = { threadId, runId, nodeId: null, providerThreadId: null, providerTurnId: null }; + const turnItem = (id: string, ordinal: number, payload: object) => ({ + id: EventId.make(`event:${id}`), + type: "turn-item.updated" as const, + threadId, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + occurredAt: now, + payload: { + ...base, + id: TurnItemId.make(id), + nativeItemRef: null, + parentItemId: null, + ordinal, + title: null, + status: "completed" as const, + startedAt: now, + completedAt: now, + updatedAt: now, + messageId: MessageId.make(id), + ...payload, + } as OrchestrationV2TurnItem, + }); + yield* eventSink.write({ + events: [ + turnItem("item:old-image", 1, { + type: "assistant_message", + text: `![Old](${shot})`, + streaming: false, + }), + ...Array.from({ length: 30 }, (_, index) => + turnItem(`item:ask-${index}`, index + 2, { + type: "user_message", + text: `Ask ${index}`, + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + attachments: [], + }), + ), + ], + }); + + const [, opening] = Array.from( + yield* Stream.runCollect(Stream.take(yield* guestShares.stream(session), 2)), + ); + if (opening?.type !== "thread" || opening.item.kind !== "snapshot") { + return assert.fail("Expected the thread snapshot second."); + } + const ids = (rows: ReadonlyArray<{ readonly sourceItemId: string }>) => + rows.map((row) => row.sourceItemId); + assert.notInclude(ids(opening.item.projection.visibleTurnItems), "item:old-image"); + let cursor = opening.item.historyCursor ?? null; + assert.isNotNull(cursor); + // The page has not shown the old reply yet, so its image does not load. + assert.equal((yield* Effect.flip(guestShares.media(session, shot))).reason, "unavailable"); + // Only a session pages. + assert.isTrue(Exit.isFailure(yield* Effect.exit(guestShares.history(credential, cursor!)))); + + const older: Array = []; + while (cursor !== null) { + const page: OrchestrationV2ThreadHistoryPage = yield* guestShares.history(session, cursor); + older.push(...ids(page.items)); + cursor = page.hasMoreHistory ? page.nextCursor : null; + } + assert.include(older, "item:old-image"); + assert.deepStrictEqual(yield* guestShares.media(session, shot), { + path: shot, + mimeType: "image/png", + }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), +); + it.effect("ends a running command's output once the owner stops sharing", () => Effect.gen(function* () { const { orchestrator, guestShares, credential } = yield* shareThread; diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 347bdc11ece5b..2a1bf853507ba 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -15,6 +15,7 @@ import { type OrchestrationV2CommandOutputFrame, type OrchestrationV2DomainEvent, type OrchestrationV2GetTurnItemResult, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2TurnItem, ThreadId, TurnItemId, @@ -185,6 +186,11 @@ export class GuestShareService extends Context.Service< session: string, itemId: string, ) => Effect.Effect; + /** Older turns of the shared thread, as the owner's client pages them. */ + readonly history: ( + session: string, + cursor: string, + ) => Effect.Effect; /** A command's output frames, as the owner's expanded row gets them, until the link ends. */ readonly commandOutput: ( session: string, @@ -665,6 +671,21 @@ const make = Effect.gen(function* () { }; }); + const history = Effect.fn("GuestShareService.history")(function* ( + session: string, + cursor: string, + ) { + const { thread, share } = yield* authorize(session, "session"); + const page = yield* orchestrator + .getThreadHistoryPage(thread.id, cursor) + .pipe(Effect.mapError(() => unavailable)); + rememberSent( + share.id, + page.items.map((row) => row.item), + ); + return page; + }); + const commandOutput = Effect.fn("GuestShareService.commandOutput")(function* ( session: string, itemId: string, @@ -739,6 +760,7 @@ const make = Effect.gen(function* () { stream, render, item, + history, commandOutput, attachment, media, diff --git a/apps/server/src/guest/http.ts b/apps/server/src/guest/http.ts index bf368ac884d92..b3e856200c845 100644 --- a/apps/server/src/guest/http.ts +++ b/apps/server/src/guest/http.ts @@ -1,6 +1,7 @@ import { GUEST_ATTACHMENT_ROUTE, GUEST_COMMAND_OUTPUT_ROUTE, + GUEST_HISTORY_ROUTE, GUEST_ITEM_ROUTE, GUEST_MEDIA_ROUTE, GUEST_RENDER_ROUTE, @@ -16,6 +17,7 @@ import { isProviderSendTurnSupportedImageMimeType, OrchestrationV2CommandOutputFrame, OrchestrationV2GetTurnItemResult, + OrchestrationV2ThreadHistoryPage, } from "@t3tools/contracts"; import * as ByteSize from "effect/ByteSize"; import * as Duration from "effect/Duration"; @@ -52,6 +54,7 @@ const encodeOutputFrame = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2CommandOutputFrame), ); const encodeItem = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2GetTurnItemResult)); +const encodeHistoryPage = Schema.encodeEffect(Schema.toCodecJson(OrchestrationV2ThreadHistoryPage)); const errorResponse = (error: GuestShareAccessError) => HttpServerResponse.json( @@ -292,6 +295,26 @@ const itemRoute = HttpRouter.add( }), ); +const historyRoute = HttpRouter.add( + "GET", + GUEST_HISTORY_ROUTE, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const cursor = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("cursor"); + if (!cursor) return yield* badRequest; + const guestShares = yield* GuestShareService; + return yield* guestShares.history(yield* sessionToken, cursor).pipe( + Effect.flatMap((page) => + encodeHistoryPage(page).pipe( + Effect.mapError(() => new GuestShareAccessError({ reason: "unavailable" })), + ), + ), + Effect.map((body) => HttpServerResponse.jsonUnsafe(body, { headers: NO_STORE })), + Effect.catchTags({ GuestShareAccessError: errorResponse }), + ); + }), +); + // Ends when the command settles; the page closes its EventSource on that last frame, so the // browser does not reconnect. const commandOutputRoute = HttpRouter.add( @@ -365,6 +388,7 @@ export const layer = Layer.mergeAll( streamRoute, renderRoute, itemRoute, + historyRoute, commandOutputRoute, attachmentRoute, mediaRoute, diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 242f92fa817b8..b2bc080ac863a 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -351,6 +351,58 @@ describe("HtmlRender", () => { }, ); + // The fake browser is a shebang script, which Windows cannot execute. + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "when the browser cannot be signalled", + () => { + // A browser T3 cannot signal, as under AppArmor in an LXC container, must still exit. + it.live("asks the browser to close when the preview finishes instead of signalling it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-fake-browser-", + }); + const executable = path.join(directory, "chrome-headless-shell"); + const exitRecord = path.join(directory, "exited"); + yield* fileSystem.writeFileString( + executable, + `#!${process.execPath} +const fs = require("node:fs"); +process.on("SIGTERM", () => {}); +const quit = () => { fs.writeFileSync(${JSON.stringify(exitRecord)}, "asked to close"); process.exit(0); }; +const reply = (message) => fs.writeSync(4, JSON.stringify(message) + "\\0"); +const results = { + "Target.createTarget": { targetId: "page" }, + "Target.attachToTarget": { sessionId: "session" }, + "Runtime.evaluate": { result: { value: 100 } }, + "Page.captureScreenshot": { data: "iVBORw0KGgo=" }, +}; +let buffered = ""; +fs.createReadStream(null, { fd: 3 }).on("end", quit).on("data", (chunk) => { + const messages = (buffered + chunk).split("\\0"); + buffered = messages.pop(); + for (const { id, method, sessionId } of messages.map((raw) => JSON.parse(raw))) { + if (method === "Browser.close") quit(); + reply({ id, sessionId, result: results[method] ?? {} }); + if (method === "Page.navigate") reply({ sessionId, method: "Page.loadEventFired", params: {} }); + } +}); +`, + { mode: 0o755 }, + ); + + yield* HtmlRender.HtmlRender.use((htmlRender) => + htmlRender.preview({ html: "

x

" }), + ).pipe(Effect.provide(layerHtmlRender(executable))); + + const exitedBy = yield* fileSystem.readFileString(exitRecord).pipe(Effect.option); + expect(Option.getOrElse(exitedBy, () => "killed by a signal")).toBe("asked to close"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + }, + ); + it.live("matches preview media queries and text colors to the requested appearance", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index aaf64bdcecc36..13ce0c6845740 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -247,7 +247,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { let disconnected: BrowserFailure | undefined; let nextId = 0; - const post = (method: string, params: Record, sessionId: string) => + const post = (method: string, params: Record, sessionId?: string) => Queue.offer( outgoing, new TextEncoder().encode( @@ -374,6 +374,18 @@ const launchBrowser = Effect.fnUntraced(function* (input: { Effect.forkScoped, ); + // Signals can miss a confined browser (AppArmor in LXC), so ask it to quit first. + // Runs before the spawn's release, which still signals a browser that stays up. + yield* Effect.addFinalizer(() => + disconnected + ? Effect.void + : post("Browser.close", {}).pipe( + Effect.andThen(child.exitCode), + Effect.timeout("2 seconds"), + Effect.ignore, + ), + ); + const send = Effect.fn("headlessChrome.send")(function* ( method: string, params: Record, 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 9f8d47d869cc0..5283bfdd64a1d 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -402,8 +402,13 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([ 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/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 9dfc8856c7865..beb471bc39805 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -421,6 +421,48 @@ it.effect("records only failed calls of tools that hand off no work", () => { ); }); +it.effect("rejects an unknown organize target instead of archiving the calling thread", () => { + const dispatched: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const outcome = yield* server + .callTool({ + name: "t3_thread_organize", + arguments: { action: "archive", threadIds: ["other-thread"] }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + Effect.result, + ); + expect(dispatched).toEqual([]); + expect(outcome).toMatchObject({ + _tag: "Failure", + failure: { message: expect.stringContaining("Expected no excess property") }, + }); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: ({ threadId: id }) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), + ), + dispatch: (command) => { + dispatched.push(command); + return Effect.succeed({ sequence: 1 } as never); + }, + }), + ), + ), + ), + ); +}); + it.effect("keeps unexpected handler defects private through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index c024752d57bd4..a7dc2426a7801 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -76,6 +76,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { ], }) .annotate(Tool.Title, "Organize a thread") + .annotate(Tool.Strict, true) .annotate(Tool.Destructive, true); const ThreadMoveTool = Tool.make("t3_thread_move", { 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 397ee1a41f418..02e6687ffece5 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -106,6 +106,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", @@ -275,6 +276,7 @@ const RPC_AGGREGATES = { [WS_METHODS.vcsCreateRef]: "vcs", [WS_METHODS.vcsSwitchRef]: "vcs", [WS_METHODS.vcsInit]: "vcs", + [WS_METHODS.reviewGetBranchCommits]: "review", [WS_METHODS.reviewGetDiffPreview]: "review", [WS_METHODS.reviewGetDiffFileContents]: "review", [WS_METHODS.terminalOpen]: "terminal", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 6dc297e9d2e80..f88d4a6fb4430 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -7506,6 +7506,96 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("lets a caller abandon Stop while hard teardown stalls, and finishes the teardown", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const teardownStarted = yield* Deferred.make(); + const releaseTeardown = yield* Deferred.make(); + let runtimeOrdinalSeen = 0; + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + interruptPromptOnCancel: true, + restartRuntimeAfterInterrupt: true, + terminateRuntimeProcessGroupOnInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + ownDetachedProcessGroup: true, + processGroupPlatform: "win32", + windowsProcessTreeTerminator: () => + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseTeardown)), + ), + environment: (runtimeOrdinal) => { + runtimeOrdinalSeen = runtimeOrdinal; + return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; + }, + protocolEvents, + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-abandoned-hard-teardown"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-abandoned-hard-teardown"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), + ) + .pipe(Effect.forkScoped); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + yield* pollProtocolMethods(protocolEvents); + const interruptFiber = yield* runtime + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) + .pipe(Effect.forkScoped); + yield* Deferred.await(teardownStarted); + + // The session manager's Stop timeout interrupts this call; it must not wait for teardown. + yield* Fiber.interrupt(interruptFiber); + + const startFiber = yield* runtime + .startTurn( + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseTeardown, undefined); + yield* Fiber.join(startFiber); + assert.equal(runtimeOrdinalSeen, 2); + const replacementMethods = yield* pollProtocolMethods(protocolEvents); + assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect( "direct Stop on a deferred subagent hold terminalizes the subagent and does not carry it forward", () => 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 484d4a425cf6d..cdb5bdf0e77bc 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), @@ -200,17 +261,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"), @@ -222,13 +297,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( @@ -236,45 +304,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( @@ -739,3 +768,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 f68ff44721842..b18f2ac1d2884 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -40,7 +40,7 @@ import { OrchestrationV2ProviderThread, ProjectId, ProviderInstanceId, - type ProviderInstanceEnvironment, + type ResolvedProviderInstanceEnvironment, ProviderDriverKind, type ProviderReplayTranscript, type ProviderApprovalDecision, @@ -236,6 +236,21 @@ function makeClaudeTestTurnInput(input: { } describe("ClaudeAdapterV2 runtime query policy", () => { + it.each([ + "-- --thinking-display omitted", + "--thinking-display summarized -- --thinking-display omitted", + ])("does not treat operands after -- as SDK flags: %s", (launchArgs) => { + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "thinking-thread", + resume: false, + cwd: "/workspace", + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs }, + }); + assert.deepEqual(options.thinking, { type: "adaptive", display: "summarized" }); + assert.equal(options.extraArgs?.["thinking-display"], "summarized"); + }); + it.each([ ["--permission-mode acceptEdits", "acceptEdits"], ["--dangerously-skip-permissions", "bypassPermissions"], @@ -270,6 +285,28 @@ describe("ClaudeAdapterV2 runtime query policy", () => { assert.include(systemPrompt.append ?? "", "a `cd` does not carry over"); }); + 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: { @@ -1169,7 +1206,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => { const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( binaryPath: string, - environment: ProviderInstanceEnvironment = [], + environment: ResolvedProviderInstanceEnvironment = [], ) { const executablePaths: Array = []; const taskToolEnvironment: Array = []; @@ -10486,6 +10523,172 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it + it.effect.each([ + { case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true }, + { case: "keeps a turn running beside a passive mod pane", focus: false }, + ] as const)("$case", ({ focus }) => + Effect.scoped( + Effect.gen(function* () { + const closed = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + close: () => Deferred.succeed(closed, undefined), + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"), + text: "Run rm -r build", + attachments: [], + }), + ); + // Captured from Claude 2.1.303 with the blast-radius mod holding a Bash + // call: the mod waits for keyboard input in its pane and nothing else + // arrives until it gives up. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [ + { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, + ], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: focus ? "blast-radius" : null, + uuid: "00000000-0000-4000-8000-000000000795", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_invalidate", + event: "ui.render", + uuid: "00000000-0000-4000-8000-000000000796", + session_id: WAKE_NATIVE_SESSION, + }), + ); + // Frames are handled in order, so a later notice proves the pane frame + // was handled without the turn ending. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content: "Processing barrier.", + level: "warning", + uuid: "00000000-0000-4000-8000-000000000797", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until( + () => + harness.terminalEvents().length > 0 || + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ), + ); + const [terminal] = harness.terminalEvents(); + if (!focus) { + assert.equal(terminal, undefined); + return; + } + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.include(terminal.failure.message, "blast-radius"); + // The held tool call is abandoned by closing the query, never answered. + yield* Deferred.await(closed); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + // regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process + it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () => + Effect.scoped( + Effect.gen(function* () { + const releaseClose = yield* Deferred.make(); + const replacementClosedOld = yield* Deferred.make(); + let oldCloses = 0; + const harness = yield* makeWakeHarnessWithOptions({ + // The held CLI never answers an interrupt; closure must not need one. + interrupt: Effect.never, + close: (sdkMessages) => + Effect.suspend(() => + ++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void, + ).pipe( + Effect.andThen(Deferred.await(releaseClose)), + Effect.andThen(Queue.shutdown(sdkMessages)), + ), + freshQueueOnReopen: true, + }); + // Added after the harness so it runs first: a failed assertion must not + // leave teardown waiting on the held close. + yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined)); + const now = yield* DateTime.now; + const turnInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"), + text: "Run rm -r build", + attachments: [], + }); + yield* harness.runtime.startTurn(turnInput); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [ + { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, + ], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: "blast-radius", + uuid: "00000000-0000-4000-8000-000000000798", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed"); + + const followUp = yield* harness.runtime + .startTurn({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"), + providerTurnOrdinal: 2, + }) + .pipe(Effect.forkScoped); + // Reusing the closing process would offer the prompt to it instead. + yield* Effect.raceFirst( + Deferred.await(replacementClosedOld), + harness.until(() => harness.offeredMessages.length >= 2), + ); + assert.isTrue(yield* Deferred.isDone(replacementClosedOld)); + yield* Deferred.succeed(releaseClose, undefined); + yield* Fiber.join(followUp); + assert.equal(harness.processQueues.length, 2); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + // regression: #16897 a prompt whose uuid the session already holds must not hang or end early it.effect("keeps a started prompt running until its result", () => Effect.scoped( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 920f6c6d45cd4..cf8078416fc44 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -144,7 +144,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, @@ -330,6 +333,8 @@ const WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS = 500; // A steer waits at most this long, from its request, for its foreground // subagents to move to the background before it is sent. const BACKGROUND_MOVE_TIMEOUT_MS = 5_000; +// How long a Stop waits for the CLI to exit before the user is offered Force stop. +const CLAUDE_STOP_CONFIRM_TIMEOUT_MS = 10_000; const CLAUDE_GROUP_WATCH_SCRIPT = [ "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &", @@ -1492,6 +1497,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, @@ -1616,6 +1623,7 @@ export function makeClaudeQueryOptions(input: { ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), ...(input.additionalDirectories ?? []), + ...claudeLaunchArgDirectories(input.settings?.launchArgs), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; @@ -4667,6 +4675,20 @@ function claudeTaskString(value: unknown, key: string): string | undefined { return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; } +// A Claude Code mod can hold a tool call until the user acts in its terminal +// pane. T3 cannot provide the requested pane interaction. +// Passive panes never request focus and keep working. +function claudeFocusRequestedPanePlugin(message: SDKMessage): string | null { + if (message.type !== "system" || claudeTaskField(message, "subtype") !== "ui_panes") return null; + const paneId = claudeTaskString(message, "focus_requested_id"); + if (paneId === undefined) return null; + const panes = claudeTaskField(message, "panes"); + const pane = Array.isArray(panes) + ? panes.find((entry) => claudeTaskField(entry, "id") === paneId) + : undefined; + return claudeTaskString(pane, "plugin") ?? paneId; +} + function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined { switch (value) { case "pending": @@ -8967,6 +8989,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } return; } + const focusedPlugin = claudeFocusRequestedPanePlugin(message); + if (focusedPlugin !== null && context === (yield* Ref.get(activeTurn))) { + yield* Effect.logWarning("orchestration-v2.claude-mod-pane-focus-unsupported", { + providerTurnId: context.providerTurnId, + plugin: focusedPlugin, + }); + liveQuery.stopping = true; + yield* finalizeActiveTurn({ + context, + status: "failed", + completedAt: yield* DateTime.now, + failure: makeProviderFailure({ + class: "provider_error", + code: "claude_mod_ui_unsupported", + message: `The Claude Code mod "${focusedPlugin}" is waiting for input in its terminal pane, which T3 Code cannot show. Disable the mod for sessions run from T3 Code, or run the task in the Claude Code terminal.`, + }), + }); + // Closing ends the CLI process and abandons the held tool call + // without answering it. Not interrupt: the held CLI may never ack it. + yield* liveQuery.query.close.pipe(Effect.ignore); + return; + } yield* trackClaudeGoal({ nativeThreadId: liveQuery.nativeThreadId, context, message }); if (message.type === "stream_event" && !message.parent_tool_use_id) { @@ -10714,8 +10758,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( existing !== null && existing.nativeThreadId === nativeThreadId && - isClaudeProviderContinuationTurn(turnInput) && - !existing.stopping + !existing.stopping && + isClaudeProviderContinuationTurn(turnInput) ) { return existing; } @@ -10746,8 +10790,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && - existing.selection.queryIdentity === compiledSelection.queryIdentity && - !existing.stopping + !existing.stopping && + existing.selection.queryIdentity === compiledSelection.queryIdentity ) { // A denied ExitPlanMode leaves Claude in plan mode. Restore only from plan: // Auto/default may reflect a policy restriction that forbids restoring bypass. @@ -11439,44 +11483,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* existing.query.interrupt; yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( - Effect.timeoutOption("10 seconds"), + Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS), ); if (Option.isSome(closed)) { return; } - - const completedAt = yield* DateTime.now; + // The turn stays open: the CLI may still be working, and the user decides + // whether to Force stop it. yield* Effect.logWarning("orchestration-v2.claude-query-interrupt-timeout", { providerSessionId: input.providerSessionId, providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, }); - const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => - current?.query === existing.query ? [true, null] : [false, current], - ); - if (ownedLiveQuery) { - existing.stopping = true; - yield* finalizeActiveTurnAfterQueryExit(existing); - yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); - } else { - yield* finalizeActiveTurn({ - context: currentTurn, - status: "interrupted", - completedAt, - }); - } - yield* Deferred.succeed(existing.closed, undefined); + return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ + driver: CLAUDE_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + timeoutMs: CLAUDE_STOP_CONFIRM_TIMEOUT_MS, + }); }, (effect, turnInput) => effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: CLAUDE_PROVIDER, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterInterruptTimeoutError" + ? cause + : new ProviderAdapter.ProviderAdapterInterruptError({ + driver: CLAUDE_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 696091ea52e89..9e9bbfca371a0 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, @@ -567,6 +568,7 @@ function codexReplayPreamble(input: { readonly nativeTurnId: string; readonly prompt: string; readonly sentPrompt?: string; + readonly effort?: string; }): Array { return [ { @@ -665,7 +667,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 }), + }, + }, }, }, }, @@ -5278,6 +5287,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"; @@ -6360,6 +6436,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 b5b2afc2802df..2542dac79ba25 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -3048,9 +3048,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, @@ -3058,6 +3055,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({ @@ -3134,9 +3134,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, @@ -4090,7 +4094,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) => @@ -4146,7 +4150,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({ diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index c894442409921..49eb6c9b3b999 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -2,7 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderReplayEntry, ProviderInstanceId, - type ProviderInstanceEnvironment, + type ResolvedProviderInstanceEnvironment, type ProviderReplayTranscript, type ProviderReplayEntry as ProviderReplayEntryType, } from "@t3tools/contracts"; @@ -363,7 +363,7 @@ export function layer(input: { readonly spawner: Layer.Layer; readonly binaryPath: string; readonly launchArgs: string; - readonly environment?: ProviderInstanceEnvironment; + readonly environment?: ResolvedProviderInstanceEnvironment; }) { return ProviderAdapterRegistry.layerFromAdaptersEffect( PiAdapterV2Driver.create({ 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/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 943589b1786fb..b98591d4a6817 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2450,6 +2450,65 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { const task = settled.subagents.find((row) => row.id === child.taskId); assert.equal(task?.status, "cancelled"); assert.equal(task?.completionDelivery?.state, "claimed"); + + // The parent's Stop committed, but the restart cut the effect that stops its children. + const stoppedChild = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "restart-parent-stopped-child", + completionWake: "always", + continuationPending: true, + now, + }); + yield* (yield* EventSink.EventSinkV2).write({ + commandId: CommandId.make("command:restart-declined-parent:stop"), + events: [ + { + id: EventId.make("event:restart-declined-parent:interrupt-request"), + type: "turn-item.updated", + threadId, + runId, + nodeId: rootNodeId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:restart-declined-parent:interrupt-request"), + type: "run_interrupt_request", + threadId, + runId, + nodeId: rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 90, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* continueRestartedRun({ + threadId: stoppedChild.childThreadId, + sourceRunId: stoppedChild.childRunId, + }).pipe(Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }))); + const afterStop = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + afterStop.subagents.find((row) => row.id === stoppedChild.taskId)?.status, + "cancelled", + ); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(stoppedChild.childThreadId)).runs.map( + (run) => run.id, + ), + [stoppedChild.childRunId], + ); }), ); diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index cb130930372c8..2329f2aa20cf0 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -111,6 +111,8 @@ function layerExecutorFor(input: { closeInstance: () => Effect.void, release: () => record("release"), detach: () => record("detach"), + otherWorkingThreads: () => Effect.succeed([]), + releaseUnlessBusy: () => Effect.succeed(true), }), ), Layer.succeed( @@ -167,7 +169,18 @@ function layerExecutorFor(input: { ); } -it.effect("settles a stopped run when its adapter has already lost the native turn", () => +it.effect.each([ + { + name: "settles a stopped run when its adapter has already lost the native turn", + unresponsive: false, + dispatched: ["thread.background-work.settle"], + }, + { + name: "leaves the run running and flags the Stop when the provider never confirms it", + unresponsive: true, + dispatched: ["thread.interrupt.unresponsive"], + }, +])("$name", ({ unresponsive, dispatched }) => Effect.gen(function* () { const now = yield* DateTime.now; const events = yield* Ref.make>([]); @@ -178,7 +191,8 @@ it.effect("settles a stopped run when its adapter has already lost the native tu threadId, operation: "interrupt", providerTurnId, - cause: "Provider turn is not active.", + ...(unresponsive ? { unresponsive } : {}), + cause: unresponsive ? "Timed out." : "Provider turn is not active.", }), threads: { dispatch: (command) => @@ -199,7 +213,7 @@ it.effect("settles a stopped run when its adapter has already lost the native tu }, }); }).pipe(Effect.provide(layer)); - assert.deepEqual(yield* Ref.get(events), ["thread.background-work.settle"]); + assert.deepEqual(yield* Ref.get(events), dispatched); }), ); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 47f7d2f09f0ab..256d0d488e96b 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -177,6 +177,13 @@ export const layerExecutor: Layer.Layer< cause, }), ), + Effect.map((outcome) => ({ unresponsive: false, outcome }) as const), + // Settling here would end the run while the agent may still be running; the + // user decides with Force stop instead. + Effect.catchIf( + (cause) => request.subagent === undefined && cause.unresponsive === true, + () => Effect.succeed({ unresponsive: true, outcome: undefined } as const), + ), Effect.catchIf( (cause) => request.subagent === undefined && @@ -184,30 +191,38 @@ export const layerExecutor: Layer.Layer< request.type, Cause.pretty(Cause.fail(cause)), ), - () => Effect.void, + () => Effect.succeed({ unresponsive: false, outcome: undefined } as const), ), - Effect.andThen((outcome) => - request.subagent !== undefined && outcome === undefined - ? Effect.void - : threads.dispatch({ - type: "thread.background-work.settle", - commandId: CommandId.make(`${effect.id}:background-work-settled`), - ...(request.subagent === undefined || outcome === undefined - ? {} - : { - subagent: { - id: request.subagent.id, - stoppedAt: DateTime.makeUnsafe(effect.createdAt), - outcome, - }, - }), + Effect.andThen(({ unresponsive, outcome }) => + unresponsive + ? threads.dispatch({ + type: "thread.interrupt.unresponsive", + commandId: CommandId.make(`${effect.id}:unresponsive`), threadId: effect.threadId, providerThreadId: request.providerThreadId, providerTurnId: request.providerTurnId, - ...(request.keepDelegatedTasks === true - ? { keepDelegatedTasks: true } - : {}), - }), + }) + : request.subagent !== undefined && outcome === undefined + ? Effect.void + : threads.dispatch({ + type: "thread.background-work.settle", + commandId: CommandId.make(`${effect.id}:background-work-settled`), + ...(request.subagent === undefined || outcome === undefined + ? {} + : { + subagent: { + id: request.subagent.id, + stoppedAt: DateTime.makeUnsafe(effect.createdAt), + outcome, + }, + }), + threadId: effect.threadId, + providerThreadId: request.providerThreadId, + providerTurnId: request.providerTurnId, + ...(request.keepDelegatedTasks === true + ? { keepDelegatedTasks: true } + : {}), + }), ), Effect.mapError( (cause) => 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 4e6e2dfa6f7b6..3d6cd2e8838fd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -262,6 +262,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 82d2237115dcb..ebc22122ea963 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -112,6 +112,8 @@ import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, + continuationFailedBeforePrompt, + continuationStartRetryDelayMs, isRestartContinuationSource, runOutcome, } from "./RestartBackgroundNote.ts"; @@ -146,6 +148,7 @@ import { type ProjectionRecords, type ProjectionCheckpointContext, type OlderSettledThreadsListing, + type ActiveShellIndexEntry, type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; @@ -398,6 +401,11 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly countThreads: () => Effect.Effect; + readonly listActiveShellIndex: () => Effect.Effect< + ReadonlyArray, + OrchestratorV2Error + >; readonly listOlderSettledThreads: ( query: OlderSettledThreadsQuery, ) => Effect.Effect; @@ -543,6 +551,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "checkpoint.rollback": case "checkpoint.rollback.fail": case "thread.background-work.settle": + case "thread.interrupt.unresponsive": case "thread.stop": case "provider.switch": return command.threadId; @@ -767,6 +776,20 @@ function delegatedTaskTerminalStatus( } } +/** Failed worktree setup keeps the queue held so queued messages cannot start in the project checkout. */ +function worktreeSetupPending( + projection: Pick, +): boolean { + return ( + projection.thread.worktreePath === null && + projection.runs.some( + (run) => + (run.status === "failed" || run.status === "interrupted") && + run.workspacePreparation?.type === "worktree", + ) + ); +} + function nextQueuedRun( projection: Pick, ): OrchestrationV2Run | undefined { @@ -2251,6 +2274,81 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); + // Under load a resumed provider can time out before taking the prompt; the restart still owes the turn. + const retryContinuationStart = Effect.fn("Orchestrator.retryContinuationStart")(function* ( + threadId: ThreadId, + runId: RunId, + ) { + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "providerTurns", "turnItems"], + { turnItemTypes: ["error"], turnItemRunIds: [runId] }, + ); + const run = projection.runs.find((candidate) => candidate.id === runId); + if (run === undefined) return; + const delayMs = continuationStartRetryDelayMs(run, projection); + if (delayMs === undefined) return; + const commandId = CommandId.make(`command:restart-continuation-retry:${run.id}`); + yield* writeSystemEvents( + [], + [ + { + id: `effect:restart-continuation:${run.id}`, + commandId, + threadId, + request: { type: "provider-runtime.continue", sourceRunId: run.id }, + availableAt: DateTime.add(yield* DateTime.now, { milliseconds: delayMs }), + }, + ], + ); + }); + + // A hold waits for a person to review what they queued. Nobody reviews messages from agents, + // the server or scheduled tasks, so a later completed run releases those unless it was stopped. + const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( + threadId: ThreadId, + completedRunId: RunId, + ) { + const held = yield* projectionStore.getHeldQueuedRuns(threadId); + if (held.length === 0) return; + const projection = yield* projectionStore.getThreadRecords( + threadId, + ["runs", "messages", "turnItems"], + { + messageIds: held.map((run) => run.userMessageId), + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [completedRunId], + }, + ); + // Only the newest run completing shows the thread moved past the hold; a late or + // bookkeeping update to an older run, overtaken by a failure or Stop, does not. + if ( + latestExecutedRun(projection.runs)?.id !== completedRunId || + projection.turnItems.length > 0 || + worktreeSetupPending(projection) + ) + return; + const { messages } = projection; + const unattended = new Set( + messages + .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) + .map((message) => message.id), + ); + const now = yield* DateTime.now; + const released = held.filter((run) => unattended.has(run.userMessageId)); + if (released.length === 0) return; + yield* writeSystemEvents( + released.map((run) => ({ + type: "run.updated" as const, + threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, queueHeld: false }, + })), + ); + }); + const resumeQueuedRuns = Effect.gen(function* () { const threadIds = yield* projectionStore.getRecoveryThreadIds("queued-runs"); let resumed = 0; @@ -3434,8 +3532,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, @@ -3582,8 +3686,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.project.move": return { ...thread, @@ -5553,7 +5668,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || projection.thread.providerInstanceId !== source.providerInstanceId || @@ -5964,15 +6082,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; // Client messages resume held queues; server continuations and scheduled tasks do not. - // Provider failure or usage limits re-hold the queue. Failed worktree setup keeps it held - // until a worktree exists, so queued messages cannot start in the project checkout. - const worktreeSetupUnresolved = - projection.thread.worktreePath === null && - projection.runs.some( - (run) => - (run.status === "failed" || run.status === "interrupted") && - run.workspacePreparation?.type === "worktree", - ); + // Provider failure or usage limits re-hold the queue. + const worktreeSetupUnresolved = worktreeSetupPending(projection); const sentByUserFromClient = command.createdBy === "user" && (command.creationSource === "web" || command.creationSource === "mobile") && @@ -10751,7 +10862,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run, rootNode, providerThread, - reason: yield* runInterruptReason(input.command, input.events, run.id), + reason: + (input.command.type === "thread.background-work.settle" + ? input.command.reason + : undefined) ?? + (yield* runInterruptReason(input.command, input.events, run.id)), completedAt: input.now, }) : { @@ -10952,7 +11067,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A resumed queue can have a lower ordinal than a pending continuation source. // Stop must reach those sources even when interrupting the resumed run succeeds. if ( - run.status === "cancelled" && + (run.status === "cancelled" || + (run.status === "failed" && run.restartContinuationOfRunId !== undefined)) && (yield* awaitsRestartContinuation(run).pipe(mapDispatchError(input.command))) ) { yield* markStoppedRun({ ...input, thread, run }); @@ -11024,6 +11140,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* closePendingLinkRequests({ ...input, threadId: thread.id }); }); + const dispatchInterruptUnresponsive = Effect.fn("Orchestrator.dispatchInterruptUnresponsive")( + function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "thread.interrupt.unresponsive" } + >, + events: Ref.Ref>, + ) { + const { providerTurn } = yield* projectionStore + .getProviderControlContext(command.threadId, { + providerThreadId: command.providerThreadId, + providerTurnId: command.providerTurnId, + }) + .pipe(mapDispatchError(command)); + const { runs } = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe(mapDispatchError(command)); + const run = runs.find( + (candidate) => + providerTurn !== undefined && candidate.activeAttemptId === providerTurn.runAttemptId, + ); + // A settled run still counts: Stop may have targeted its background work. + if (run === undefined) return; + const { turnItems } = yield* projectionStore + .getThreadRecords(command.threadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request", "run_interrupt_result"], + turnItemRunIds: [run.id], + }) + .pipe(mapDispatchError(command)); + // A provider that stopped late already wrote the result; it needs no Force stop. + if (turnItems.some((item) => item.type === "run_interrupt_result")) return; + const request = turnItems.find((item) => item.type === "run_interrupt_request"); + if (request?.type !== "run_interrupt_request" || request.unresponsiveAt !== undefined) return; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + runId: run.id, + ...(request.nodeId === null ? {} : { nodeId: request.nodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...request, unresponsiveAt: now, updatedAt: now }, + }); + }, + ); + const dispatchBackgroundWorkSettle = Effect.fn("Orchestrator.dispatchBackgroundWorkSettle")( function* ( command: Extract< @@ -11582,6 +11747,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: { ...run, status: "interrupted", completedAt: now }, }); + // Stop cancels a pending restart, so it must end the turn that restart could not stop. + const supersededTurn = projection.providerTurns.findLast( + (candidate) => + candidate.status === "running" && + projection.attempts.some( + (attempt) => attempt.id === candidate.runAttemptId && attempt.runId === run.id, + ), + ); + const supersededOwner = + supersededTurn === undefined + ? undefined + : projection.providerThreads.find( + (candidate) => candidate.id === supersededTurn.providerThreadId, + ); + if ( + supersededTurn !== undefined && + supersededOwner !== undefined && + supersededOwner.providerSessionId !== null && + Option.isSome( + yield* providerSessions + .get(supersededOwner.providerSessionId) + .pipe(Effect.orElseSucceed(() => Option.none())), + ) + ) { + const id = `effect:${command.commandId}:provider-turn.interrupt:${supersededTurn.id}`; + if (!otherProviderInterrupts.some((effect) => effect.id === id)) { + otherProviderInterrupts.push({ + id, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "provider-turn.interrupt", + providerSessionId: supersededOwner.providerSessionId, + providerThreadId: supersededOwner.id, + providerTurnId: supersededTurn.id, + }, + }); + } + } yield* stopRemainingWork(); yield* Ref.update(effects, (existing) => [...existing, ...otherProviderInterrupts]); return { @@ -13444,6 +13648,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events, effects); break; + case "thread.interrupt.unresponsive": + yield* dispatchInterruptUnresponsive(command, events); + break; case "thread.fork": yield* dispatchThreadFork(command, events); break; @@ -13574,6 +13781,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (planned) => planned.events.length > 0 || command.type === "thread.background-work.settle" || + command.type === "thread.interrupt.unresponsive" || command.type === "thread.stop" || command.type === "subagent.stop" || command.type === "thread.scheduled-task.set" || @@ -13746,6 +13954,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // while writing child events, so nesting parent inside child would invert the order. // The keyed semaphores are neither reentrant nor deadlock-aware. if (delegatedTaskTerminalStatus(run.status) !== null) { + // Before the parent hears of the failure: a pending continuation keeps the task open. + if (run.status === "failed" && run.restartContinuationOfRunId !== undefined) { + yield* threadDispatch.withLock(threadId, retryContinuationStart(threadId, run.id)).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to schedule a restart continuation retry", { + threadId, + runId: run.id, + cause, + }), + ), + ); + } yield* threadDispatch.withLock( threadId, finalizeDelegatedCompletionDelivery(threadId, run.id), @@ -13753,10 +13973,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* threadDispatch .withLock( threadId, - startNextQueuedRun(threadId, { - endedRunId: run.id, - ...(run.status === "failed" ? { failedRunId: run.id } : {}), - }), + (run.status === "completed" + ? releaseUnattendedHolds(threadId, run.id) + : Effect.void + ).pipe( + Effect.andThen( + startNextQueuedRun(threadId, { + endedRunId: run.id, + ...(run.status === "failed" ? { failedRunId: run.id } : {}), + }), + ), + ), ) .pipe( Effect.catchCause((cause) => @@ -14110,6 +14337,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..4de38ff587709 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,45 @@ 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" + | "hiddenAt" + | "groupName" + | "scheduledTaskId" + >, + ) => ({ + id: row.id, + projectId: row.projectId, + lineage: row.lineage, + settledOverride: row.settledOverride, + settledAt: iso(row.settledAt), + pinnedAt: iso(row.pinnedAt), + snoozedUntil: iso(row.snoozedUntil), + hiddenAt: iso(row.hiddenAt), + groupName: row.groupName ?? null, + scheduledTaskId: row.scheduledTaskId ?? null, + }); + 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 +312,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 +572,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 +1378,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 +1417,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 +3841,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 +4904,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 +6511,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 +6686,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 fab5d066190ef..1e5db4e05c0b0 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -73,6 +73,7 @@ import { NodeId, ProjectId, ProviderInstanceId, + ScheduledTaskId, } from "@t3tools/contracts"; import { createOrchestrationV2TurnItemVisibility, @@ -395,6 +396,24 @@ 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" + | "hiddenAt" + | "groupName" + | "scheduledTaskId" +> { + /** 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; @@ -457,6 +476,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, @@ -606,6 +632,9 @@ export interface ProjectionStoreV2Shape { readonly canStartQueuedRun: ( threadId: ThreadId, ) => Effect.Effect; + readonly getHeldQueuedRuns: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly getRecoveryThreadIds: ( kind: ProjectionRecoveryKind, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -5211,6 +5240,17 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + const getHeldQueuedRuns: ProjectionStoreV2Shape["getHeldQueuedRuns"] = (threadId) => + sql` + SELECT payload_json FROM orchestration_v2_projection_runs + WHERE thread_id = ${threadId} AND status = 'queued' + AND json_extract(payload_json, '$.queueHeld') IS 1 + ORDER BY ordinal ASC + `.pipe( + Effect.flatMap(decodeRows(decodeRunPayload, threadId)), + Effect.mapError(controlReadError(threadId)), + ); + const getMessageCount: ProjectionStoreV2Shape["getMessageCount"] = (threadId) => sql<{ count: number; @@ -5996,7 +6036,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 `; @@ -6009,14 +6049,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 `; @@ -6031,7 +6071,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 @@ -6048,23 +6088,23 @@ 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 = ( threadIds?: ReadonlyArray, activeRunIds: ReadonlyArray = [], ) => sql` - SELECT i.thread_id, i.payload_json, p.provider_instance_id AS owning_provider_instance_id - FROM orchestration_v2_projection_turn_items i - LEFT JOIN orchestration_v2_projection_provider_threads p - ON p.provider_thread_id = i.provider_thread_id - LEFT JOIN orchestration_v2_projection_runs r - ON r.run_id = i.run_id - 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)}`} + SELECT i.thread_id, i.payload_json, p.provider_instance_id AS owning_provider_instance_id + FROM orchestration_v2_projection_turn_items i + LEFT JOIN orchestration_v2_projection_provider_threads p + ON p.provider_thread_id = i.provider_thread_id + LEFT JOIN orchestration_v2_projection_runs r + ON r.run_id = i.run_id + 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 (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ${ activeRunIds.length === 0 ? sql`` @@ -6073,7 +6113,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') ` @@ -6672,6 +6712,108 @@ 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 hidden_at: string | null; + readonly group_name: string | null; + readonly scheduled_task_id: 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, + json_extract(t.payload_json, '$.hiddenAt') AS hidden_at, + json_extract(t.payload_json, '$.groupName') AS group_name, + json_extract(t.payload_json, '$.scheduledTaskId') AS scheduled_task_id, + -- 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), + hiddenAt: row.hidden_at === null ? null : DateTime.makeUnsafe(row.hidden_at), + groupName: row.group_name, + scheduledTaskId: + row.scheduled_task_id === null ? null : ScheduledTaskId.make(row.scheduled_task_id), + mayBeLive: row.may_be_live === 1, + })); + }, Effect.mapError(shellSnapshotReadError)); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( shareId: GuestShareId, ) { @@ -6826,6 +6968,8 @@ export const layer: Layer.Layer = apply, getShellSnapshot: readOnReader(getShellSnapshot), listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds), + countThreads: readOnReader(countThreads), + listActiveShellIndex: readOnReader(listActiveShellIndex), readShellSnapshot: readOnReader(readShellSnapshot), getProjectMoveBlocker: (threadId) => sql<{ readonly blocker: "chat_tabs" | "scheduled_tasks" }>` @@ -6862,6 +7006,7 @@ export const layer: Layer.Layer = getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext), getRunMessage: readOnReader(getRunMessage), canStartQueuedRun: readOnReader(canStartQueuedRun), + getHeldQueuedRuns: readOnReader(getHeldQueuedRuns), getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs), hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest), isNodeStored: readOnReader(isNodeStored), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index cac8e4fbbb8e0..5bbb3926e2b66 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -338,6 +338,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly backgroundTasksEndWithProcess?: boolean; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; + readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; @@ -437,7 +438,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( Ref.update(state, (current) => ({ ...current, interruptCount: current.interruptCount + 1, - })), + })).pipe(Effect.andThen(options.interruptTurn ?? Effect.void)), unloadThread: ({ providerThread }) => (options.beforeUnload ?? Effect.void).pipe( Effect.andThen( @@ -485,6 +486,7 @@ function layerTest(input: { readonly backgroundTasksEndWithProcess?: boolean; readonly hangSessionScopeClose?: boolean; readonly startTurn?: Effect.Effect; + readonly interruptTurn?: Effect.Effect; readonly beforeUnload?: Effect.Effect; readonly spawnBeforeOpen?: boolean; readonly scopeCloseReached?: Deferred.Deferred; @@ -520,6 +522,7 @@ function layerTest(input: { ? {} : { hangSessionScopeClose: input.hangSessionScopeClose }), ...(input.startTurn === undefined ? {} : { startTurn: input.startTurn }), + ...(input.interruptTurn === undefined ? {} : { interruptTurn: input.interruptTurn }), ...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }), ...(input.spawnBeforeOpen === undefined ? {} : { spawnBeforeOpen: input.spawnBeforeOpen }), ...(input.scopeCloseReached === undefined @@ -1367,6 +1370,159 @@ it.effect( }), ); +it.effect("ProviderSessionManagerV2 gives up on a provider that never confirms a Stop", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-unresponsive-stop"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const stopping = yield* runtime + .interruptTurn({ + providerThread: makeProviderThread({ idAllocator, threadId, providerSessionId, now }), + providerTurnId: idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "native-turn-unresponsive", + }), + }) + .pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust(ProviderSessionManager.PROVIDER_INTERRUPT_TIMEOUT_MS); + assert.equal((yield* Fiber.join(stopping))._tag, "ProviderAdapterInterruptTimeoutError"); + }).pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, interruptTurn: Effect.never })), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps a shared session a sibling is busy on when releasing", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const stopping = ThreadId.make("thread-provider-session-manager-release-stopping"); + const sibling = ThreadId.make("thread-provider-session-manager-release-sibling"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: stopping, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: sibling, now }), + ], + }); + const runtime = yield* manager.open({ + threadId: stopping, + providerSessionId, + modelSelection, + runtimePolicy, + }); + yield* manager.open({ + threadId: sibling, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThreadOf = (threadId: ThreadId) => + makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const runId = idAllocator.derive.run({ threadId: sibling, ordinal: 1 }); + yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(sibling)).thread, + threadId: sibling, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread: providerThreadOf(sibling), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId: sibling, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + const release = (own: ThreadId) => + manager.releaseUnlessBusy({ + providerSessionId, + ownProviderThreadIds: [providerThreadOf(own).id], + reason: "manual_shutdown", + }); + + assert.isFalse(yield* release(stopping)); + assert.isTrue(yield* manager.isLive(providerSessionId)); + assert.isTrue(yield* release(sibling)); + assert.isFalse(yield* manager.isLive(providerSessionId)); + + // A turn that starts on a runtime released meanwhile fails instead of running on it. + const startOnReleasedRuntime = Effect.gen(function* () { + const lateRunId = idAllocator.derive.run({ threadId: stopping, ordinal: 1 }); + return yield* Effect.flip( + runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(stopping)).thread, + threadId: stopping, + runId: lateRunId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId: lateRunId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId: lateRunId }), + providerThread: providerThreadOf(stopping), + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId: stopping, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }), + ); + }); + assert.equal((yield* startOnReleasedRuntime)._tag, "ProviderAdapterTurnStartError"); + + // Nor may it leave its turn busy on a replacement opened under the same id. + yield* manager.open({ + threadId: sibling, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* startOnReleasedRuntime)._tag, "ProviderAdapterTurnStartError"); + assert.isTrue(yield* release(sibling)); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 60 * 60_000 }))); + }), +); + it.effect("ProviderSessionManagerV2 stops a session still opening when its layer shuts down", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -2551,6 +2707,10 @@ it.effect("ProviderSessionManagerV2 releases sessions when provider event stream assert.isTrue(Option.isNone(liveSession)); assert.equal(runtimeState.closeCount, 1); assert.equal(projection.providerSessions.at(-1)?.status, "error"); + assert.equal( + projection.providerSessions.at(-1)?.lastError, + "The provider event stream closed unexpectedly. Retry the turn; if it keeps failing, check the provider and server logs.", + ); }); yield* effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index df2ee630df38b..03a7278774a64 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -50,17 +50,26 @@ import * as CommandOutputHub from "./CommandOutputHub.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; const DEFAULT_MAX_IDLE_PIN_MS = 4 * 60 * 60 * 1000; const RELEASE_SCOPE_CLOSE_TIMEOUT_MS = 30 * 1000; +/** Past adapters' own 10 s stop fallbacks, so they settle first when the provider answers at all. */ +export const PROVIDER_INTERRUPT_TIMEOUT_MS = 15 * 1000; const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; +const WORKING_RUN_STATUSES: ReadonlySet = new Set([ + "preparing", + "starting", + "running", + "waiting", +]); export const ProviderSessionReleaseReason = Schema.Literals([ "idle_timeout", @@ -180,6 +189,25 @@ export interface ProviderSessionManagerV2Shape { readonly detail?: string; readonly revokeMcpCredential?: boolean; }) => Effect.Effect; + /** + * Releases the session unless, at the moment of removal, a turn of a provider thread outside + * `ownProviderThreadIds` is busy on it. Checked atomically with turn starts. Succeeds with + * whether it was released. + */ + readonly releaseUnlessBusy: (input: { + readonly providerSessionId: ProviderSessionId; + readonly ownProviderThreadIds: ReadonlyArray; + readonly reason: ProviderSessionReleaseReason; + readonly detail?: string; + }) => Effect.Effect; + /** + * Other threads with a run or background work on this session's shared process; empty when + * it serves one thread. + */ + readonly otherWorkingThreads: (input: { + readonly providerSessionId: ProviderSessionId; + readonly threadId: ThreadId; + }) => Effect.Effect>; } export class ProviderSessionManagerV2 extends Context.Service< @@ -894,6 +922,7 @@ export const layerWithOptions = ( function* (input: { readonly providerSessionId: ProviderSessionId; readonly onlyIfIdleGeneration?: number; + readonly keepIfBusyOtherThan?: ReadonlyArray; }): Effect.fn.Return, DateTime.Utc]> { const key = sessionKey(input.providerSessionId); const candidate = (yield* Ref.get(sessions)).get(key); @@ -913,6 +942,15 @@ export const layerWithOptions = ( ) { return ["kept", current] as const; } + const own = input.keepIfBusyOtherThan; + if ( + own !== undefined && + [...existing.busyTurns].some( + (turnKey) => !own.some((id) => turnKey.startsWith(busyTurnPrefix(id))), + ) + ) { + return ["kept", current] as const; + } const updated = new Map(current); updated.delete(key); return ["removed", updated] as const; @@ -971,6 +1009,7 @@ export const layerWithOptions = ( readonly detachedThreadId?: ThreadId; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; + readonly keepIfBusyOtherThan?: ReadonlyArray; readonly gracefulSubscribers?: boolean; }) => Effect.acquireUseRelease( @@ -1379,15 +1418,21 @@ export const layerWithOptions = ( .pipe(Effect.ensuring(Effect.sync(dropReservation))); }); - const markBusy = (providerSessionId: ProviderSessionId, turnKey: string) => + // Marks only the entry this runtime belongs to: a released or replaced session must not + // gain a busy turn that never ends. Succeeds with whether the turn was marked. + const markBusy = ( + providerSessionId: ProviderSessionId, + turnKey: string, + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + ) => withActivityError( providerSessionId, Effect.gen(function* () { const key = sessionKey(providerSessionId); - const idleFiber = yield* Ref.modify(sessions, (current) => { + const marked = yield* Ref.modify(sessions, (current) => { const entry = current.get(key); - if (entry === undefined) { - return [null, current] as const; + if (entry === undefined || entry.runtime !== runtime) { + return [undefined, current] as const; } const updated = new Map(current); updated.set(key, { @@ -1396,9 +1441,11 @@ export const layerWithOptions = ( idleFiber: null, pinnedSinceMs: null, }); - return [entry.idleFiber, updated] as const; + return [{ idleFiber: entry.idleFiber }, updated] as const; }); - yield* cancelIdleFiber(idleFiber); + if (marked === undefined) return false; + yield* cancelIdleFiber(marked.idleFiber); + return true; }), ); @@ -1784,11 +1831,31 @@ export const layerWithOptions = ( ), Effect.andThen( Effect.acquireUseRelease( - observeActivity( + markBusy( providerSessionId, - markBusy( - providerSessionId, - busyTurnKey(input.providerThread.id, input.runOrdinal), + busyTurnKey(input.providerThread.id, input.runOrdinal), + runtime, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.driver-session.activity-failed", { + providerSessionId, + cause, + }).pipe(Effect.as(true)), + ), + // A release between attach and markBusy leaves nothing to mark: fail the + // start instead of running it on a runtime being torn down. + Effect.flatMap((marked) => + marked + ? Effect.void + : Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: runtime.driver, + threadId: input.threadId, + providerThreadId: input.providerThread.id, + runId: input.runId, + cause: "The provider session was released while the turn started.", + }), + ), ), ), () => @@ -1807,9 +1874,26 @@ export const layerWithOptions = ( observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( Effect.andThen(runtime.steerTurn(input).pipe(turnMetrics("steer"))), ), + // A provider starved of memory can stop answering, so no caller may wait on it unbounded. interruptTurn: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( - Effect.andThen(runtime.interruptTurn(input).pipe(turnMetrics("interrupt"))), + Effect.andThen( + runtime.interruptTurn(input).pipe( + Effect.timeoutOrElse({ + duration: PROVIDER_INTERRUPT_TIMEOUT_MS, + orElse: () => + Effect.fail( + new ProviderAdapter.ProviderAdapterInterruptTimeoutError({ + driver: runtime.driver, + providerThreadId: input.providerThread.id, + providerTurnId: input.providerTurnId, + timeoutMs: PROVIDER_INTERRUPT_TIMEOUT_MS, + }), + ), + }), + turnMetrics("interrupt"), + ), + ), ), respondToRuntimeRequest: (input) => observeActivity(providerSessionId, touchActivity(providerSessionId)).pipe( @@ -1987,10 +2071,16 @@ export const layerWithOptions = ( cause, }); yield* Ref.set(entry.eventSubscribers, new Map()); + // lastError is the thread banner, so it gets the same bounded + // message as a failed turn; the raw cause survives only in the log. + yield* Effect.logWarning("orchestration-v2.provider-session-runtime-failed", { + providerSessionId: entry.runtime.providerSessionId, + cause, + }); yield* releaseEntry({ providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", - detail: Cause.pretty(cause), + detail: makeProviderFailure({ cause }).message, }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); }), ), @@ -2269,6 +2359,40 @@ export const layerWithOptions = ( ), ), release: releaseEntry, + releaseUnlessBusy: Effect.fn("ProviderSessionManager.releaseUnlessBusy")(function* (input) { + const key = sessionKey(input.providerSessionId); + const before = (yield* Ref.get(sessions)).get(key); + yield* releaseEntry({ + providerSessionId: input.providerSessionId, + reason: input.reason, + ...(input.detail === undefined ? {} : { detail: input.detail }), + keepIfBusyOtherThan: input.ownProviderThreadIds, + }); + // Identity, not presence: open can install a replacement under the same id meanwhile. + return before !== undefined && (yield* Ref.get(sessions)).get(key) !== before; + }), + otherWorkingThreads: Effect.fn("ProviderSessionManager.otherWorkingThreads")( + function* (input) { + const entry = (yield* Ref.get(sessions)).get(sessionKey(input.providerSessionId)); + if (entry === undefined || !entry.supportsMultipleProviderThreads) return []; + return yield* Effect.filter( + [...entry.attachedThreadIds].filter((threadId) => threadId !== input.threadId), + (threadId) => + // Runs, not the shell's activeRunId: the shell omits silent runs such as idle + // compaction. Background work outlives its run and still needs the process. + Effect.zipWith( + projectionStore.getThreadRecords(threadId, ["runs"]), + projectionStore.getThreadShell(threadId), + ({ runs }, shell) => + runs.some((run) => WORKING_RUN_STATUSES.has(run.status)) || + (shell?.pendingBackgroundTasks?.length ?? 0) > 0, + ).pipe( + // An unreadable thread may still be working; never kill it unseen. + Effect.orElseSucceed(() => true), + ), + ); + }, + ), detach: Effect.fn("ProviderSessionManager.detach")( function* (input) { const key = sessionKey(input.providerSessionId); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 27568e4522c37..61229559ab170 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"), getProjectMoveBlocker: () => Effect.succeed(null), @@ -263,6 +265,7 @@ it.effect( getRunMessage: () => Effect.die("not used"), isNodeStored: () => Effect.die("not used"), canStartQueuedRun: () => Effect.die("not used"), + getHeldQueuedRuns: () => Effect.die("not used"), getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"), getThreadSnapshot: () => Effect.die("unused getThreadSnapshot"), @@ -283,6 +286,8 @@ it.effect( closeInstance: () => Effect.void, release: () => Effect.void, detach: () => Effect.void, + otherWorkingThreads: () => Effect.succeed([]), + releaseUnlessBusy: () => Effect.succeed(true), }), ); const layerControl = ProviderTurnControlService.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index e1dbac21bc52e..bd225bb41773b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -38,6 +38,8 @@ export class ProviderTurnControlError extends Schema.TaggedError + Effect.fail( + new ProviderTurnControlError({ + threadId: input.threadId, + operation: "interrupt", + providerTurnId: input.providerTurnId, + unresponsive: true, + cause, + }), + ), + }), + ); // Use real time; ingestion fibers do not advance TestClock and would leave Stop blocked. yield* Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 2_000; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index aad1e0036a208..c92c6d6a2a19f 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1342,6 +1342,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 563adaebe1e39..ca1b696fa0c12 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -93,6 +93,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError; @@ -181,6 +181,50 @@ export function cancelledBeforeStart( return run.status === "cancelled" && neverStarted(run); } +/** The provider never accepted this continuation's prompt, so starting it again repeats nothing. */ +export function continuationFailedBeforePrompt( + run: Pick, + providerTurns: ReadonlyArray>, +): boolean { + return ( + run.status === "failed" && + run.restartContinuationOfRunId !== undefined && + !providerTurns.some((turn) => turn.runAttemptId === run.activeAttemptId) + ); +} + +const CONTINUATION_START_RETRY_DELAYS_MS = [60_000, 180_000]; + +/** Backoff before restarting a continuation that failed to start, or undefined to let it fail. */ +export function continuationStartRetryDelayMs( + run: OrchestrationV2Run, + projection: { + readonly runs: ReadonlyArray; + readonly providerTurns: ReadonlyArray>; + readonly turnItems: Parameters[1]; + }, +): number | undefined { + if (!continuationFailedBeforePrompt(run, projection.providerTurns)) return; + // These fail the same way however often they start. + const failureClass = latestRootProviderFailure(run, projection.turnItems)?.class; + if ( + failureClass === "usage_limit" || + failureClass === "permission_error" || + failureClass === "validation_error" + ) + return; + let failedStarts = 0; + for ( + let current: OrchestrationV2Run | undefined = run; + current !== undefined && continuationFailedBeforePrompt(current, projection.providerTurns); + current = projection.runs.find( + (candidate) => candidate.id === current?.restartContinuationOfRunId, + ) + ) + failedStarts += 1; + return CONTINUATION_START_RETRY_DELAYS_MS[failedStarts - 1]; +} + export function isRestartContinuationSource( source: Pick, providerTurns: ReadonlyArray>, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 061fc54a7f112..d2224720b89f1 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSettings from "../serverSettings.ts"; import { restartContinuationRun, continueRestartedRun } from "./RestartContinuation.ts"; +import { continuationStartRetryDelayMs } from "./RestartBackgroundNote.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -46,6 +47,8 @@ function makeProjection() { providerInstanceId: instanceId, archivedAt: null, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, }, runs: [ { @@ -839,10 +842,13 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = ( + projection: OrchestrationV2ThreadProjection, + sourceRunId: RunId = runId, +) => Effect.gen(function* () { const dispatches: Array> = []; - yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + yield* continueRestartedRun({ threadId, sourceRunId }).pipe( Effect.provide( Layer.merge( Layer.mock(ThreadManagementService.ThreadManagementService)({ @@ -943,6 +949,118 @@ it.effect("continues a resumed queued run that ran after an earlier continuation }), ); +it("backs off twice from a continuation the provider never took, then lets it fail", () => { + const base = makeProjection(); + const failedStart = (id: string, of: RunId) => ({ + ...base.runs[0]!, + id: RunId.make(id), + activeAttemptId: RunAttemptId.make(`attempt:${id}`), + status: "failed" as const, + restartContinuationOfRunId: of, + }); + const first = failedStart("run:retry-1", runId); + const second = failedStart("run:retry-2", first.id); + const third = failedStart("run:retry-3", second.id); + const projection = (failureClass = "provider_error", providerTurns = base.providerTurns) => + ({ + runs: [{ ...base.runs[0]!, status: "cancelled" }, first, second, third], + providerTurns, + turnItems: [first, second, third].map((run) => ({ + id: `error:${run.id}`, + type: "error", + status: "failed", + runId: run.id, + nodeId: run.rootNodeId, + ordinal: 1, + updatedAt: now, + failure: { class: failureClass, message: "Failed.", code: null, retryable: null }, + })), + }) as unknown as Parameters[1]; + assert.deepEqual( + [first, second, third].map((run) => continuationStartRetryDelayMs(run, projection())), + [60_000, 180_000, undefined], + ); + for (const failureClass of ["usage_limit", "permission_error", "validation_error"]) + assert.isUndefined(continuationStartRetryDelayMs(first, projection(failureClass))); + // Once the provider took the prompt, starting again would repeat the turn. + assert.isUndefined( + continuationStartRetryDelayMs( + first, + projection("provider_error", [ + { ...base.providerTurns[0]!, runAttemptId: first.activeAttemptId, status: "failed" }, + ] as never), + ), + ); +}); + +it.effect("continues again after a continuation fails before its prompt", () => + Effect.gen(function* () { + const cut = cutMidTurn(); + const failedStart = { + ...cut.runs[0]!, + id: RunId.make("run:failed-start"), + ordinal: 2, + activeAttemptId: RunAttemptId.make("attempt:failed-start"), + status: "failed", + restartContinuationOfRunId: runId, + }; + const dispatches = yield* continuationDispatches( + { ...cut, runs: [...cut.runs, failedStart] } as unknown as OrchestrationV2ThreadProjection, + failedStart.id, + ); + assert.deepEqual( + dispatches.map((command) => command.restartContinuationOfRunId), + [failedStart.id], + ); + }), +); + +it.effect.each([ + ["running", null, 0], + ["completed", "Done.", 1], +] as const)( + "a parent's Stop on the task's run blocks the child only while the task is %s", + ([status, result, expected]) => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:parent"); + const parentRunId = RunId.make("run:parent"); + const taskId = NodeId.make("node:task"); + const cut = cutMidTurn(); + const child = { + ...cut, + thread: { + ...cut.thread, + lineage: { parentThreadId, relationshipToParent: "subagent" }, + forkedFrom: { type: "node", nodeId: taskId }, + }, + } as unknown as OrchestrationV2ThreadProjection; + const parent = { + thread: { id: parentThreadId }, + subagents: [{ id: taskId, runId: parentRunId, origin: "app_owned", status, result }], + turnItems: [{ id: "turn-item:stop", runId: parentRunId, type: "run_interrupt_request" }], + }; + let dispatched = 0; + yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id) => + Effect.succeed((id === parentThreadId ? parent : child) as never), + recoverDelegatedTask: () => Effect.void, + dispatch: () => + Effect.sync(() => { + dispatched += 1; + return {} as never; + }), + }), + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + ), + ), + ); + assert.equal(dispatched, expected); + }), +); + it.effect("does not continue a run the user asked to stop before the restart", () => Effect.gen(function* () { const texts = yield* continuationTexts({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index c81faa6cf47cb..e4d7ac2b7f900 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -2,6 +2,7 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + isOrchestrationV2WorkActive, latestProviderTurnForAttempt, MessageId, type OrchestrationV2Run, @@ -19,6 +20,7 @@ import { awaitingProviderThreadId, cancelledRosterTaskWork, cancelledTurnItemWork, + continuationFailedBeforePrompt, isAwaitedWork, isRestartContinuationSource, cancelledBeforeStart, @@ -200,7 +202,10 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const source = projection.runs.find((run) => run.id === input.sourceRunId); if ( !source || - !isRestartContinuationSource(source, projection.providerTurns) || + !( + isRestartContinuationSource(source, projection.providerTurns) || + continuationFailedBeforePrompt(source, projection.providerTurns) + ) || restartContinuationBlocked( projection, source, @@ -255,6 +260,32 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ) ) return; + // A parent's Stop reaches its open delegated tasks through an effect a restart can cut short. + const { lineage, forkedFrom } = projection.thread; + const taskId = + source.delegatedTaskId ?? (forkedFrom?.type === "node" ? forkedFrom.nodeId : undefined); + if ( + lineage.relationshipToParent === "subagent" && + lineage.parentThreadId !== null && + taskId !== undefined + ) { + const task = (yield* threads.getThreadRecords(lineage.parentThreadId, ["subagents"], { + subagentIds: [taskId], + })).subagents.find((candidate) => candidate.id === taskId); + // A settled task no longer owns later work in its thread. + const parentRunId = + task !== undefined && task.result === null && isOrchestrationV2WorkActive(task.status) + ? task.runId + : null; + if ( + parentRunId !== null && + (yield* threads.getThreadRecords(lineage.parentThreadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [parentRunId], + })).turnItems.length > 0 + ) + return; + } const sourceMessage = sourceRecords.messages.find( (message) => message.id === source.userMessageId, ); diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 4de981d972c54..85354334192c0 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -446,6 +446,118 @@ it.live("retries a failed selection restart session open before completing", () ), ); +it.live("Stop during a restart interrupts the turn the restart could not stop", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("selection-restart-stop"); + const threadId = ThreadId.make("thread:selection-restart-stop"); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const base = makeRestartAdapter(state); + // A wedged provider ignores the restart's soft cancel; only Stop ends its turn. + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + ...base, + openSession: (input) => + base.openSession(input).pipe( + Effect.map((session) => ({ + ...session, + interruptTurn: (interrupt: ProviderAdapter.ProviderAdapterV2InterruptInput) => + interrupt.requestRuntimeRestart === true + ? session.interruptTurn(interrupt) + : Effect.never, + })), + ), + }; + + const result = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const running = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:create"), + threadId, + projectId: ProjectId.make("project:selection-restart-stop"), + title: "Stop during restart", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:first"), + threadId, + messageId: MessageId.make("message:selection-restart-stop:first"), + text: "first", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* Fiber.join(running); + const runId = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!.id; + + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:selection-restart-stop:steer"), + threadId, + messageId: MessageId.make("message:selection-restart-stop:steer"), + text: "Are you still going?", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "restart_active", targetRunId: runId }, + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("command:selection-restart-stop:stop"), + threadId, + runId, + }); + yield* worker.drain(); + return { + projection: yield* orchestrator.getThreadProjection(threadId), + captured: yield* Ref.get(state), + }; + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "selection-restart-stop" }, + ProviderAdapterRegistry.layerSingle(adapter), + ), + ), + ); + + assert.isNull(result.captured.activeTurn); + assert.lengthOf(result.captured.started, 1); + assert.equal(result.projection.runs[0]?.status, "interrupted"); + assert.deepEqual( + result.projection.providerTurns.map((turn) => turn.status), + ["interrupted"], + ); + }), + ), +); + it.live( "restarts the live session on a model change when a newer stopped session record exists", () => diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index b73c87beab805..e28b31e061b52 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -31,6 +31,7 @@ import type * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; import { bufferLatestLiveStream } from "./LiveStreamBudget.ts"; +import type { ActiveShellIndexEntry } from "./ProjectionStore.ts"; type ShellDelta = Extract; @@ -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,28 +71,124 @@ 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.hiddenAt != null || + thread.groupName != null || + thread.scheduledTaskId != null || + 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)); +} + /** * The active rows a shell subscriber gets, and the subagent ids whose transcript events its stream * drops. The ids come from every row, so an omitted child that wakes keeps that filter. @@ -103,18 +200,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,81 +220,110 @@ 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.hiddenAt != null || + entry.groupName != null || + entry.scheduledTaskId != null || + 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.hiddenAt != null || - // Group visibility is client-local; its page must work before Settled is loaded. - thread.groupName != null || - thread.scheduledTaskId != null || - 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 0241dea6e19c1..ca38c00d3a987 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, @@ -27,7 +28,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"); @@ -118,8 +121,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.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 4e62d731287e1..954ac0e1cdba7 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -438,12 +438,9 @@ const make = Effect.gen(function* () { ); const visited = new Set([input.threadId]); while (true) { - const snapshot = yield* threads.getShellSnapshot(); + const snapshot = yield* threads.getShellSnapshot({ worktreePath: input.worktreePath }); const sharing = [...snapshot.threads, ...snapshot.archivedThreads].filter( - (thread) => - !visited.has(thread.id) && - thread.worktreePath === input.worktreePath && - thread.branch === input.oldBranch, + (thread) => !visited.has(thread.id) && thread.branch === input.oldBranch, ); if (sharing.length === 0) break; for (const thread of sharing) { @@ -1378,12 +1375,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); @@ -1405,30 +1404,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 @@ -1444,7 +1446,7 @@ const make = Effect.gen(function* () { input.threadId, run.id, ); - }; + }); const prepareMessageWorkspace: ThreadLaunchService["Service"]["prepareMessageWorkspace"] = Effect.fn("ThreadLaunchService.prepareMessageWorkspace")( diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index bbc36e7145cf4..9a418cac59002 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -33,6 +33,8 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; +import { AgentScope } from "@t3tools/shared/AgentScope"; + import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; @@ -1409,3 +1411,174 @@ it.effect.each([ expect(dispatched).toEqual(settles ? ["thread.settle"] : []); }), ); + +it.effect.each([ + { + name: "kills the thread's own agent and ends its turn", + others: [] as ReadonlyArray, + killed: true, + expected: { outcome: "killed", sharedWithThreadIds: [] }, + teardown: ["release"], + }, + { + name: "keeps a process another working thread shares and only ends this turn", + others: ["thread:thread-management:force-stop:other"], + killed: false, + expected: { + outcome: "shared", + sharedWithThreadIds: ["thread:thread-management:force-stop:other"], + }, + teardown: ["detach"], + }, + { + name: "still settles a finished run's background work in a shared process", + others: ["thread:thread-management:force-stop:other"], + killed: false, + settledRun: true, + expected: { + outcome: "shared", + sharedWithThreadIds: ["thread:thread-management:force-stop:other"], + }, + teardown: ["detach"], + }, + { + name: "detaches when a sibling starts a turn just before the session is released", + others: [] as ReadonlyArray, + killed: false, + siblingBusy: true, + expected: { outcome: "shared", sharedWithThreadIds: [] }, + teardown: ["release", "detach"], + }, + { + name: "reports a teardown that has not finished in time as still releasing", + others: [] as ReadonlyArray, + killed: false, + releaseHangs: true, + expected: { outcome: "releasing", sharedWithThreadIds: [] }, + teardown: ["release"], + }, +])("Force stop $name", ({ others, killed, expected, teardown, ...row }) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:thread-management:force-stop"); + const providerSessionId = ProviderSessionId.make("provider-session:force-stop"); + const providerThreadId = ProviderThreadId.make("provider-thread:force-stop"); + const runId = RunId.make("run:thread-management:force-stop"); + const dispatched: Array = []; + const teardowns: Array = []; + const tornDown = yield* Deferred.make(); + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + isLive: () => Effect.succeed(true), + otherWorkingThreads: () => Effect.succeed(others.map((id) => ThreadId.make(id))), + releaseUnlessBusy: () => + Effect.sync(() => teardowns.push("release")).pipe( + Effect.andThen( + "releaseHangs" in row ? Effect.never : Effect.succeed(!("siblingBusy" in row)), + ), + ), + detach: () => + Effect.sync(() => teardowns.push("detach")).pipe( + Effect.andThen(Deferred.succeed(tornDown, undefined)), + Effect.asVoid, + ), + }), + ), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { id: threadId, deletedAt: null }, + runs: [ + { + id: runId, + ordinal: 1, + status: "settledRun" in row ? "completed" : "running", + activeAttemptId: "attempt", + }, + // A queued follow-up neither supersedes the request nor becomes the target. + { id: RunId.make("run:queued-follow-up"), ordinal: 2, status: "queued" }, + ], + providerThreads: [{ id: providerThreadId, appThreadId: threadId, providerSessionId }], + providerTurns: [ + { + id: "provider-turn:force-stop", + providerThreadId, + runAttemptId: "attempt", + status: "settledRun" in row ? "completed" : "running", + }, + ], + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + ), + Layer.provide( + Layer.succeed(AgentScope, { + ...AgentScope.defaultValue(), + kill: () => Effect.succeed(killed), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + const stopping = yield* service.forceStopThread({ threadId, runId }).pipe(Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + expect(yield* Fiber.join(stopping)).toEqual(expected); + expect(dispatched).toMatchObject([ + { + type: "thread.background-work.settle", + providerThreadId, + reason: "Force stopped by user", + }, + ]); + if (teardown.at(-1) === "detach") yield* Deferred.await(tornDown); + expect(teardowns).toEqual(teardown); + }).pipe(Effect.provide(layerTest)); + }), +); + +it.effect("Force stop refuses once a newer run has started, before killing anything", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:thread-management:force-stop-superseded"); + let killed = false; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { id: threadId, deletedAt: null }, + runs: [ + { id: RunId.make("run:stopped"), ordinal: 1, status: "running" }, + { id: RunId.make("run:newer"), ordinal: 2, status: "running" }, + ], + providerThreads: [], + providerTurns: [], + } as unknown as OrchestrationV2ThreadProjection), + }), + ), + Layer.provide( + Layer.succeed(AgentScope, { + ...AgentScope.defaultValue(), + kill: () => Effect.sync(() => (killed = true)), + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const error = yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return yield* Effect.flip( + service.forceStopThread({ threadId, runId: RunId.make("run:stopped") }), + ); + }).pipe(Effect.provide(layerTest)); + expect(error.reason).toBe("superseded"); + expect(killed).toBe(false); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 730d007324d96..04bb0f3e221f2 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -15,6 +15,9 @@ import { MessageId, OrchestrationV2StopWorkflowError, type OrchestrationV2StopWorkflowInput, + OrchestrationV2ForceStopThreadError, + type OrchestrationV2ForceStopThreadInput, + type OrchestrationV2ForceStopThreadResult, type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, @@ -31,6 +34,7 @@ import { ProjectId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, ProviderInstanceId, + type ProviderSessionId, resolveProjectProviderInstanceEnabled, RunId, type ScheduledTaskId, @@ -39,10 +43,13 @@ import { } from "@t3tools/contracts"; import { delegatedTaskProgress } from "@t3tools/provider-core/server/subagentProjection"; import { threadTranscriptHeader } from "@t3tools/shared/threadTranscript"; +import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -414,10 +421,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, @@ -469,6 +481,13 @@ export interface ThreadManagementServiceShape { readonly stopWorkflow: ( input: OrchestrationV2StopWorkflowInput, ) => Effect.Effect; + /** + * Kills the thread's agent for a provider that no longer responds, then ends + * its turn as interrupted. Never kills a process other working threads share. + */ + readonly forceStopThread: ( + input: OrchestrationV2ForceStopThreadInput, + ) => Effect.Effect; readonly awaitBackgroundTasks: (input: { readonly threadId: ThreadId; readonly taskIds: ReadonlyArray; @@ -562,12 +581,16 @@ function latestSteerableRun( } const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; +const FORCE_STOP_REASON = "Force stopped by user"; +const FORCE_STOP_RELEASE_WAIT = "10 seconds"; +const isForceStopThreadError = Schema.is(OrchestrationV2ForceStopThreadError); const make = Effect.gen(function* () { const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; const orchestrator = yield* Orchestrator.OrchestratorV2; const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; + const agentScope = yield* AgentScope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; const serverSettings = yield* ServerSettings.ServerSettingsService; const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; @@ -1108,6 +1131,153 @@ const make = Effect.gen(function* () { return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; }); + const forceStopThread: ThreadManagementServiceShape["forceStopThread"] = Effect.fn( + "ThreadManagementService.forceStopThread", + )( + function* (input) { + // Under the thread lock no new run can be created between the check and the kill. The + // settlement below dispatches, which takes that lock itself, so it runs after release. + const { records, target, killed } = yield* threadDispatch.withLock( + input.threadId, + Effect.gen(function* () { + const records = yield* orchestrator.getThreadRecords(input.threadId, [ + "runs", + "providerThreads", + "providerTurns", + ]); + // The run Force stop acts on: the active one, else the latest that started. Queued + // follow-ups never ran, so they neither count as targets nor supersede the request. + const target = + latestActiveRun(records) ?? + records.runs + .filter((run) => run.status !== "queued") + .reduce( + (current, run) => + current === undefined || run.ordinal > current.ordinal ? run : current, + undefined, + ); + if (input.runId !== undefined && input.runId !== target?.id) { + return yield* new OrchestrationV2ForceStopThreadError({ + threadId: input.threadId, + reason: "superseded", + }); + } + // Shared scopes are never tracked per thread, so this only kills the thread's own agent. + const killed = yield* agentScope.kill(input.threadId); + return { records, target, killed }; + }), + ); + const sessionIds = new Set( + records.providerThreads.flatMap((providerThread) => + providerThread.appThreadId === input.threadId && providerThread.providerSessionId !== null + ? [providerThread.providerSessionId] + : [], + ), + ); + // A run that already ended may still hold background work; settling it is a no-op otherwise. + const run = target; + if (run !== undefined) { + const active = isActiveRun(run); + const providerTurn = records.providerTurns.findLast( + (turn) => + turn.runAttemptId === run.activeAttemptId && (!active || turn.status === "running"), + ); + const commandId = CommandId.make( + `server:force-stop:${run.id}:${yield* Clock.currentTimeMillis}`, + ); + yield* providerTurn === undefined && !active + ? Effect.void + : providerTurn === undefined + ? orchestrator.dispatch({ + type: "run.interrupt", + commandId, + threadId: input.threadId, + runId: run.id, + reason: FORCE_STOP_REASON, + }) + : orchestrator.dispatch({ + type: "thread.background-work.settle", + commandId, + threadId: input.threadId, + providerThreadId: providerTurn.providerThreadId, + providerTurnId: providerTurn.id, + reason: FORCE_STOP_REASON, + }); + } + const ownProviderThreadIds = records.providerThreads.flatMap((providerThread) => + providerThread.appThreadId === input.threadId ? [providerThread.id] : [], + ); + const detach = (providerSessionId: ProviderSessionId) => + sessions + .detach({ providerSessionId, threadId: input.threadId, detail: FORCE_STOP_REASON }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.force-stop-detach-failed", { + threadId: input.threadId, + cause, + }), + ), + Effect.forkIn(layerScope), + ); + let shared = false; + const sharedWith = new Set(); + const releases: Array<{ + readonly providerSessionId: ProviderSessionId; + readonly fiber: Fiber.Fiber; + }> = []; + for (const providerSessionId of sessionIds) { + if (!(yield* sessions.isLive(providerSessionId))) continue; + // Decided just before teardown, so the reply matches what happens to the process. + const others = yield* sessions.otherWorkingThreads({ + providerSessionId, + threadId: input.threadId, + }); + for (const threadId of others) sharedWith.add(threadId); + if (others.length > 0) { + shared = true; + yield* detach(providerSessionId); + continue; + } + // The session manager re-checks atomically with turn starts and keeps a session a + // sibling began using in the meantime. + const fiber = yield* sessions + .releaseUnlessBusy({ + providerSessionId, + ownProviderThreadIds, + reason: "manual_shutdown", + detail: FORCE_STOP_REASON, + }) + .pipe(Effect.forkIn(layerScope)); + releases.push({ providerSessionId, fiber }); + } + // Teardown waits on the provider's own exit paths; past this bound it keeps going on its own. + let releasing = false; + for (const release of releases) { + const released = yield* Fiber.join(release.fiber).pipe( + Effect.timeoutOption(FORCE_STOP_RELEASE_WAIT), + ); + if (Option.isNone(released)) { + releasing = true; + } else if (!released.value) { + shared = true; + yield* detach(release.providerSessionId); + } + } + return { + outcome: killed ? "killed" : shared ? "shared" : releasing ? "releasing" : "released", + sharedWithThreadIds: [...sharedWith], + } as const; + }, + (effect, input) => + effect.pipe( + Effect.mapError((cause) => + isForceStopThreadError(cause) + ? cause + : new OrchestrationV2ForceStopThreadError({ threadId: input.threadId, cause }), + ), + ), + ); + const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = Effect.fn( "ThreadManagementService.stopWorkflow", )( @@ -1374,6 +1544,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, @@ -1388,6 +1560,7 @@ const make = Effect.gen(function* () { interruptThread, stopDelegatedTasks, stopWorkflow, + forceStopThread, awaitBackgroundTasks, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, 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/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 36b3b529da703..a20059dd61cae 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -1594,3 +1594,115 @@ it.effect("a Stop that keeps background work leaves delegated tasks and watches assert.deepEqual((yield* threadState(childThreadId)).runs, childRunsBefore); }).pipe(Effect.provide(layerTest)), ); + +it.effect("an unanswered Stop is flagged until Force stop ends the run with its own reason", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:force-stop"); + yield* createWatchingThread(threadId, 6); + yield* send(threadId, "work", "start_immediately"); + const now = yield* DateTime.now; + const started = yield* orchestrator.getThreadProjection(threadId); + const run = started.runs[0]!; + const providerThreadId = run.providerThreadId!; + const providerTurnId = ProviderTurnId.make("provider-turn:force-stop"); + const apply = (event: Omit) => + projections.apply({ + ...event, + id: EventId.make(`event:force-stop:${event.type}`), + threadId, + } as OrchestrationV2DomainEvent); + const attempt = started.attempts[0]!; + const attemptId = attempt.id; + yield* apply({ + type: "run.updated", + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now, activeAttemptId: attemptId }, + }); + yield* apply({ + type: "run-attempt.updated", + occurredAt: now, + payload: { ...attempt, status: "running", startedAt: now }, + }); + yield* apply({ + type: "provider-turn.updated", + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId, + nodeId: run.rootNodeId!, + runAttemptId: attemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* apply({ + type: "turn-item.updated", + runId: run.id, + occurredAt: now, + payload: { + id: TurnItemId.make("turn-item:force-stop:interrupt-request"), + threadId, + runId: run.id, + nodeId: run.rootNodeId!, + providerThreadId, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "run_interrupt_request", + message: "Stopped by user", + }, + }); + const target = { threadId, providerThreadId, providerTurnId }; + const request = Effect.map(orchestrator.getThreadProjection(threadId), (projection) => + projection.turnItems.find((item) => item.type === "run_interrupt_request"), + ); + + yield* orchestrator.dispatch({ + ...target, + type: "thread.interrupt.unresponsive", + commandId: CommandId.make("force-stop:unresponsive"), + }); + const flagged = yield* request; + assert.isTrue( + flagged?.type === "run_interrupt_request" && flagged.unresponsiveAt !== undefined, + ); + assert.deepEqual((yield* threadState(threadId)).runs, ["running"]); + + yield* orchestrator.dispatch({ + ...target, + type: "thread.background-work.settle", + commandId: CommandId.make("force-stop:settle"), + reason: "Force stopped by user", + }); + const settled = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + settled.runs.map((candidate) => candidate.status), + ["interrupted"], + ); + const result = settled.turnItems.find((item) => item.type === "run_interrupt_result"); + assert.equal( + result?.type === "run_interrupt_result" ? result.message : undefined, + "Force stopped by user", + ); + + // A provider confirming late changes nothing once the run has ended. + const late = yield* orchestrator.dispatch({ + ...target, + type: "thread.interrupt.unresponsive", + commandId: CommandId.make("force-stop:late-unresponsive"), + }); + assert.lengthOf(late.storedEvents, 0); + }).pipe(Effect.provide(layerTest)), +); 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 fea1c8cfdfe09..7245719380fda 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, @@ -397,6 +400,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 90d40e111dad5..a9eb02242cee5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5008,6 +5008,265 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // A thread fed by agent messages, with helpers to end its runs as a provider would. + const queueTestThread = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Dispatcher", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = (key: string, createdBy: "user" | "agent", start: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy, + creationSource: createdBy === "user" ? "web" : "server", + commandId: CommandId.make(`${threadId}:message:${key}`), + threadId, + messageId: MessageId.make(`${threadId}:message:${key}`), + text: key, + attachments: [], + modelSelection, + dispatchMode: { type: start ? "start_immediately" : "queue_after_active" }, + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => { + const run = runs.find( + (candidate) => candidate.userMessageId === MessageId.make(`${threadId}:message:${key}`), + ); + assert.isDefined(run); + return run; + }); + const startedRunIds = yield* Queue.unbounded(); + // Run updates in commit order; the run-update listener handles them in this order too. + const runUpdates: Array = []; + yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.runForEach((stored) => { + if (stored.event.type !== "run.updated") return Effect.void; + runUpdates.push(stored.event.payload); + return stored.event.payload.status === "starting" + ? Queue.offer(startedRunIds, stored.event.payload.id) + : Effect.void; + }), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const releasedAfter = (key: string, completedKey: string) => + Effect.gen(function* () { + const [run, completed] = [yield* runFor(key), yield* runFor(completedKey)]; + const released = runUpdates.findIndex( + (update) => update.id === run.id && update.queueHeld === false, + ); + const completion = runUpdates.findIndex( + (update) => update.id === completed.id && update.status === "completed", + ); + assert.isAbove(released, completion, `${key} was released before ${completedKey} ended`); + }); + const end = ( + key: string, + status: "completed" | "failed", + revision = "", + patch: Partial = {}, + ) => + Effect.gen(function* () { + const run = yield* runFor(key); + assert.isNotNull(run.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...(status === "failed" + ? [ + { + id: EventId.make(`${threadId}:${key}:error`), + type: "turn-item.updated" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:${key}:error`), + type: "error" as const, + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed" as const, + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error" as const, + message: "Provider failed.", + code: "provider_failed", + retryable: null, + }, + }, + }, + ] + : []), + { + id: EventId.make(`${threadId}:${key}:${status}${revision}`), + type: "run.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, ...patch, status, startedAt: now, completedAt: now }, + }, + ], + }); + }); + + // The run-update listener places failure holds after the terminal event commits. + const awaitHeld = (key: string) => + Effect.gen(function* () { + const run = yield* runFor(key); + yield* eventSink.stream({ threadId, afterSequence: 0, eventType: "run.updated" }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === run.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + ); + }); + return { send, runFor, end, startedRunIds, releasedAfter, awaitHeld }; + }); + + it.effect("releases held agent messages once a later run completes, not the user's", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make("runtime-layer-failed-queue-agent-messages"); + const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = + yield* queueTestThread(threadId); + yield* send("earlier", "agent", true); + yield* end("earlier", "completed"); + yield* send("active", "agent", true); + yield* send("issue", "agent", false); + yield* send("user", "user", false); + yield* end("active", "failed"); + yield* awaitHeld("issue"); + assert.isTrue((yield* runFor("issue")).queueHeld); + // Delivery bookkeeping rewrites an older completed run; the thread has not moved on. + yield* end("earlier", "completed", ":bookkeeping"); + // A scheduled tick finds the thread idle and starts past the held queue. + yield* send("tick", "agent", true); + assert.equal((yield* runFor("tick")).status, "starting"); + yield* end("tick", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("issue")).id); + yield* releasedAfter("issue", "tick"); + const user = yield* runFor("user"); + assert.equal(user.status, "queued"); + assert.isTrue(user.queueHeld); + + // A run that finishes despite a stop request does not release what the stop held. + yield* send("after-stop", "agent", false); + assert.isTrue((yield* runFor("after-stop")).queueHeld); + const issue = yield* runFor("issue"); + assert.isNotNull(issue.rootNodeId); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:issue:interrupt-request`), + type: "turn-item.updated", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerInstanceId: issue.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:issue:interrupt-request`), + type: "run_interrupt_request", + threadId, + runId: issue.id, + nodeId: issue.rootNodeId, + providerThreadId: issue.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "completed", + title: "Interrupt requested", + startedAt: now, + completedAt: now, + updatedAt: now, + message: "Stopped by user", + }, + }, + ], + }); + yield* end("issue", "completed"); + // The next completed run releases it, which also shows the listener handled the stopped one. + yield* send("probe", "agent", true); + assert.equal((yield* runFor("probe")).status, "starting"); + yield* end("probe", "completed"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("after-stop")).id); + yield* releasedAfter("after-stop", "probe"); + }), + ); + + it.effect("keeps held agent messages while the thread's worktree setup is unresolved", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const dispatcherId = ThreadId.make("runtime-layer-held-queue-worktree"); + const dispatcher = yield* queueTestThread(dispatcherId); + const witness = yield* queueTestThread(ThreadId.make("runtime-layer-held-queue-witness")); + yield* dispatcher.send("setup", "agent", true); + yield* dispatcher.send("issue", "agent", false); + yield* dispatcher.end("setup", "failed", "", { + workspacePreparation: { type: "worktree", baseRef: "main" }, + }); + yield* dispatcher.awaitHeld("issue"); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + yield* dispatcher.send("tick", "agent", true); + assert.equal((yield* dispatcher.runFor("tick")).status, "starting"); + yield* witness.send("active", "agent", true); + yield* witness.send("queued", "agent", false); + // The failed setup never produced the thread's worktree. + const { thread } = yield* orchestrator.getThreadProjection(dispatcherId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${dispatcherId}:worktree-missing`), + type: "thread.metadata-updated", + threadId: dispatcherId, + occurredAt: yield* DateTime.now, + payload: { ...thread, worktreePath: null }, + }, + ], + }); + yield* dispatcher.end("tick", "completed"); + // One listener handles every thread's completions in order, so the witness starting + // means the tick's completion was handled too. + yield* witness.end("active", "completed"); + assert.equal(yield* Queue.take(witness.startedRunIds), (yield* witness.runFor("queued")).id); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + }), + ); + it.effect("keeps queued user selections and pauses only the limited provider", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -5446,6 +5705,165 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([false, true])( + "restarts a continuation that failed before its prompt, unless stopped (%s)", + (stopped) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make(`runtime-layer-continuation-start-retry-${stopped}`); + const messageId = (key: string) => MessageId.make(`${threadId}:${key}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make(`${threadId}:project`), + title: "Continuation start retry", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const runFor = (key: string) => + Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.find((candidate) => candidate.userMessageId === messageId(key)), + ); + const write = ( + key: string, + payload: OrchestrationV2Run, + extra: Parameters[0]["events"] = [], + ) => + Effect.gen(function* () { + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + ...extra, + { + id: EventId.make(`${threadId}:${key}`), + type: "run.updated", + threadId, + runId: payload.id, + providerInstanceId: payload.providerInstanceId, + occurredAt: now, + payload: { ...payload, startedAt: now, completedAt: now }, + }, + ], + }); + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:cut`), + threadId, + messageId: messageId("cut"), + text: "Work that the restart cut", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const cut = (yield* runFor("cut"))!; + yield* write("cut:cancelled", { ...cut, status: "cancelled" }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "agent", + creationSource: "server", + commandId: CommandId.make(`${threadId}:message:continuation`), + threadId, + messageId: messageId("continuation"), + text: "Continue where you left off.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + restartContinuationOfRunId: cut.id, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}:message:queued`), + threadId, + messageId: messageId("queued"), + text: "Queued behind the continuation", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const continuation = (yield* runFor("continuation"))!; + assert.isNotNull(continuation.rootNodeId); + const queued = (yield* runFor("queued"))!; + // The failure holds the queue after the reactor scheduled any retry. + const held = yield* eventSink + .stream({ threadId, afterSequence: yield* orchestrator.getThreadEventSequence(threadId) }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === queued.id && + stored.event.payload.queueHeld === true, + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* Effect.yieldNow; + const now = yield* DateTime.now; + yield* write("continuation:failed", { ...continuation, status: "failed" }, [ + { + id: EventId.make(`${threadId}:continuation:error`), + type: "turn-item.updated", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerInstanceId: continuation.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:continuation:error`), + type: "error", + threadId, + runId: continuation.id, + nodeId: continuation.rootNodeId, + providerThreadId: continuation.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Provider failure", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "provider_error", + message: "Claude timed out while registering its MCP tools.", + code: null, + retryable: null, + }, + }, + }, + ]); + yield* Fiber.join(held); + const retriesOf = Effect.map(orchestrator.getThreadProjection(threadId), ({ runs }) => + runs.filter((run) => run.restartContinuationOfRunId === continuation.id), + ); + yield* worker.drain(); + assert.deepEqual(yield* retriesOf, [], "the retry waits out its backoff"); + if (stopped) { + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make(`${threadId}:stop`), + threadId, + }); + } + yield* TestClock.adjust("61 seconds"); + yield* worker.drain(); + assert.lengthOf(yield* retriesOf, stopped ? 0 : 1); + }), + ); + it.effect.each(["startup", "shutdown"] as const)( "preserves and holds queued messages across %s until explicitly resumed", (trigger) => @@ -5612,9 +6030,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { after.runs.find((run) => run.id === held.id), held, ); + // Nobody will resume a server message, so the completed run released it behind the wake. assert.deepEqual( after.runs.find((run) => run.id === textContinuation.id), - textContinuation, + { ...textContinuation, queueHeld: false }, ); }), ); @@ -5954,7 +6373,7 @@ it.layer(layerTest)("snooze queued runs", (it) => { payload: run.status === "queued" ? { ...run, queueHeld: true } - : { ...run, status: "completed" as const, completedAt: now }, + : { ...run, status: "cancelled" as const, completedAt: now }, })), effects: [], }); @@ -6518,6 +6937,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/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 021afcce1ae10..4d67f6b7f1c41 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -75,6 +75,7 @@ import Migration0069 from "./Migrations/069_ThreadMessageSearch.ts"; import Migration0070 from "./Migrations/070_PeerLinks.ts"; import Migration0071 from "./Migrations/071_PeerLinkFence.ts"; import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; +import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -150,6 +151,7 @@ const migrationEntries = [ [70, "PeerLinks", Migration0070], [71, "PeerLinkFence", Migration0071], [72, "WaitingLinkRequestIndex", Migration0072], + [73, "ActiveTurnItemIndexes", Migration0073], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts b/apps/server/src/persistence/Migrations/073_ActiveTurnItemIndexes.ts new file mode 100644 index 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 17e8291c93862..2c32e6af589ca 100644 --- a/apps/server/src/persistence/Sqlite.ts +++ b/apps/server/src/persistence/Sqlite.ts @@ -39,6 +39,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/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index d0651baf0e11e..e502322211081 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -43,6 +43,7 @@ import { SERVER_BROWSER_AUTOMATION_CLIENT_ID, type PreviewAppearancePreference, } from "@t3tools/contracts"; +import { AgentScope, agentScopeLauncherScript } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; @@ -461,6 +462,8 @@ const make = Effect.gen(function* () { const runPromise = Effect.runPromiseWith(yield* Effect.context()); const launchServices = yield* Effect.context(); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); + const agentScope = yield* AgentScope; + const launchersDir = NodePath.join(config.stateDir, "server-browser", "launchers"); const tabs = new Map(); const pendingTabs = new Map>(); @@ -474,6 +477,17 @@ const make = Effect.gen(function* () { const contexts = new ServerBrowserContexts({ profilesDir: NodePath.join(config.stateDir, "server-browser", "profiles"), executable: () => runPromise(previewBrowser.executable), + // An agent scope keeps Chrome out of the server's memory protection and OOM score. + launcher: async (executable) => { + const launch = await runPromise( + agentScope.wrap({ command: executable, args: [], name: "preview-browser" }), + ); + if (launch.command === executable) return undefined; + await NodeFSP.mkdir(launchersDir, { recursive: true }); + const launcher = NodePath.join(launchersDir, NodeCrypto.randomUUID()); + await NodeFSP.writeFile(launcher, agentScopeLauncherScript(launch), { mode: 0o700 }); + return { path: launcher, release: () => NodeFSP.rm(launcher, { force: true }) }; + }, // Playwright's launch error drops Chrome's own output; its sandbox note survives. diagnose: (executable, cause) => Effect.runPromiseWith(launchServices)( diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 7a3babd0dcc0a..3cab864de54f2 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -55,6 +55,17 @@ const options = () => ({ env: {}, }); +const withLauncher = () => { + const release = vi.fn(async () => {}); + return { + release, + configuration: { + ...options(), + launcher: vi.fn(async (executable: string) => ({ path: `${executable}-launcher`, release })), + }, + }; +}; + beforeEach(() => { launches.launch.mockReset(); launches.persistent.mockReset(); @@ -66,19 +77,29 @@ describe("ServerBrowserContexts", () => { it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => { const browser = makeBrowser(); const launch = Promise.withResolvers(); - launches.launch.mockReturnValue(launch.promise); - const configuration = options(); + const started = Promise.withResolvers(); + launches.launch.mockImplementation(() => { + started.resolve(); + return launch.promise; + }); + const { configuration, release } = withLauncher(); const pool = new ServerBrowserContexts(configuration); expect(configuration.executable).not.toHaveBeenCalled(); const first = pool.contextFor("default", "agent-a"); const again = pool.contextFor("default", "agent-a"); const second = pool.contextFor("default", "agent-b"); const incognito = pool.contextFor(INCOGNITO_BROWSER_PROFILE_ID); + await started.promise; + // Playwright may retry the same path until the launch settles. + expect(release).not.toHaveBeenCalled(); launch.resolve(browser as unknown as Browser); const [a, reused, b, privateContext] = await Promise.all([first, again, second, incognito]); + expect(release).toHaveBeenCalledTimes(1); expect(a).toBe(reused); expect(new Set([a, b, privateContext]).size).toBe(3); - expect(launches.launch).toHaveBeenCalledTimes(1); + expect(launches.launch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ executablePath: "/test/chromium-launcher" }), + ); expect(launches.persistent).not.toHaveBeenCalled(); expect(browser.newContext).toHaveBeenCalledWith({ viewport: { width: 1280, height: 800 }, @@ -94,19 +115,44 @@ describe("ServerBrowserContexts", () => { const browser = makeBrowser(); launches.persistent.mockResolvedValue(human as unknown as BrowserContext); launches.launch.mockResolvedValue(browser as unknown as Browser); - const pool = new ServerBrowserContexts(options()); + const { configuration, release } = withLauncher(); + const pool = new ServerBrowserContexts(configuration); const persistent = await pool.contextFor("work/team"); expect(await pool.contextFor("work/team")).toBe(persistent); expect(await pool.contextFor("work/team", "agent")).not.toBe(persistent); expect(launches.persistent).toHaveBeenCalledExactlyOnceWith( "/test/profiles/work%2Fteam", - expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium" }), + expect.objectContaining({ chromiumSandbox: true, executablePath: "/test/chromium-launcher" }), ); + expect(release).toHaveBeenCalledTimes(2); expect(launches.mkdir).toHaveBeenCalledWith("/test/profiles/work%2Fteam", { recursive: true }); await pool.close(); expect(human.close).toHaveBeenCalledTimes(1); }); + it("diagnoses a launcher failure against the real executable", async () => { + const diagnosed = new Error("diagnosed"); + const diagnose = vi.fn(async () => diagnosed); + const pool = new ServerBrowserContexts({ + ...options(), + diagnose, + launcher: vi.fn(async () => { + throw new Error("EACCES"); + }), + }); + await expect(pool.contextFor("default", "agent")).rejects.toBe(diagnosed); + expect(diagnose).toHaveBeenCalledExactlyOnceWith("/test/chromium", expect.any(Error)); + expect(launches.launch).not.toHaveBeenCalled(); + }); + + it("creates no launcher for a profile whose storage cannot be created", async () => { + launches.mkdir.mockRejectedValueOnce(new Error("EACCES")); + const { configuration } = withLauncher(); + const pool = new ServerBrowserContexts(configuration); + await expect(pool.contextFor("work/team")).rejects.toThrow("EACCES"); + expect(configuration.launcher).not.toHaveBeenCalled(); + }); + it("clears a profile by closing its open context before deleting its storage", async () => { const human = makeContext(); const browser = makeBrowser(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 0e0f71d4e53ca..9f3fd48430752 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -17,6 +17,13 @@ const loadPlaywright = () => interface Options { readonly profilesDir: string; readonly executable: () => Promise; + /** + * A path that starts `executable`, such as one that moves Chrome out of the server's cgroup. + * Released after the whole launch call, since Playwright retries the same path after a glibc race. + */ + readonly launcher?: ( + executable: string, + ) => Promise<{ readonly path: string; readonly release: () => Promise } | undefined>; readonly diagnose?: (executable: string, cause: unknown) => Promise; readonly env?: NodeJS.ProcessEnv; readonly onContextClose?: (context: BrowserContext) => void; @@ -79,10 +86,10 @@ export class ServerBrowserContexts { } private async launchOptions() { - const executablePath = await this.options.executable(); + const executable = await this.options.executable(); const env = this.options.env ?? process.env; return { - executablePath, + executable, env, args: [ "--disable-gpu", @@ -96,19 +103,26 @@ export class ServerBrowserContexts { }; } - private async launch(options: { readonly executablePath: string }, start: () => Promise) { + private async launch(executable: string, start: (executablePath: string) => Promise) { + let release: (() => Promise) | undefined; try { - return await start(); + const launcher = await this.options.launcher?.(executable); + release = launcher?.release; + return await start(launcher?.path ?? executable); } catch (cause) { - throw (await this.options.diagnose?.(options.executablePath, cause)) ?? cause; + throw (await this.options.diagnose?.(executable, cause)) ?? cause; + } finally { + await release?.().catch(constVoid); } } private sharedBrowser() { if (!this.browser) { - const launched = this.launchOptions().then(async (options) => { + const launched = this.launchOptions().then(async ({ executable, ...options }) => { const { chromium } = loadPlaywright(); - const browser = await this.launch(options, () => chromium.launch(options)); + const browser = await this.launch(executable, (executablePath) => + chromium.launch({ ...options, executablePath }), + ); browser.on("disconnected", () => { if (this.browser === launched) this.browser = undefined; }); @@ -156,11 +170,15 @@ export class ServerBrowserContexts { return browser.newContext(contextOptions); } const directory = this.profileDirectory(profileId); - const options = await this.launchOptions(); - const { chromium } = loadPlaywright(); await NodeFSP.mkdir(directory, { recursive: true }); - return this.launch(options, () => - chromium.launchPersistentContext(directory, { ...options, ...contextOptions }), + const { executable, ...options } = await this.launchOptions(); + const { chromium } = loadPlaywright(); + return this.launch(executable, (executablePath) => + chromium.launchPersistentContext(directory, { + ...options, + ...contextOptions, + executablePath, + }), ); } diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 94ca314ac1f33..2a9998f4cfbf6 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -110,6 +110,8 @@ const withFakeSystemd = ( ...fileSystem, readFileString: (path, encoding) => fileSystem.readFileString(path.replace("/sys/fs/cgroup", cgroupRoot), encoding), + writeFileString: (path, data, options) => + fileSystem.writeFileString(path.replace("/sys/fs/cgroup", cgroupRoot), data, options), }), ); const { stateDir } = yield* ServerConfig.ServerConfig; @@ -213,6 +215,36 @@ describe("AgentScope service", () => { ), ); + it.effect.each([{ oomPolicy: true }, { oomPolicy: false }])( + "Force stop kills a thread's own scope but never a shared Codex scope (OOM policy $oomPolicy)", + ({ oomPolicy }) => + withFakeSystemd( + ({ scope, units, calls, cgroupRoot }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ + command: "agent", + args: [], + name: "t", + threadId: "a", + }); + exitedAgent(units, cgroupRoot, unitOf(agent), { populated: true, oomKills: 0 }); + const codex = yield* launchCodex(scope, "b"); + exitedAgent(units, cgroupRoot, unitOf(codex), { populated: true, oomKills: 0 }); + + expect(yield* scope.kill("a")).toBe(true); + expect( + NodeFS.readFileSync(NodePath.join(cgroupRoot, unitOf(agent), "cgroup.kill"), "utf8"), + ).toBe("1"); + expect(yield* scope.kill("b")).toBe(false); + expect(NodeFS.existsSync(NodePath.join(cgroupRoot, unitOf(codex), "cgroup.kill"))).toBe( + false, + ); + expect(calls.filter(([, verb]) => verb === "kill")).toEqual([]); + }), + { oomPolicy }, + ), + ); + it.effect("keeps a running scope while hundreds of other threads launch", () => withFakeSystemd(({ scope, units }) => Effect.gen(function* () { @@ -245,6 +277,34 @@ describe("AgentScope service", () => { ), ); + it.effect("caps agent memory and yields CPU, disk, and tasks to the server", () => + withFakeSystemd(({ scope, calls }) => + Effect.gen(function* () { + yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const setProperties = calls.filter(([, verb]) => verb === "set-property"); + expect(setProperties).toEqual([ + [ + "--user", + "set-property", + "--runtime", + "app-t3code-agents.slice", + expect.stringMatching(/^MemoryHigh=\d+$/), + expect.stringMatching(/^MemoryMax=\d+$/), + ], + [ + "--user", + "set-property", + "--runtime", + "app-t3code.slice", + "CPUWeight=10", + "IOWeight=10", + "TasksMax=25%", + ], + ]); + }), + ), + ); + it.effect("stops agents a killed server left before the first launch", () => withFakeSystemd(({ scope, units, calls, stateDir }) => Effect.gen(function* () { diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index cc6d3a04121f6..b9b6b56acb140 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -19,6 +19,12 @@ import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; const AGENT_SLICE = "app-t3code-agents.slice"; +// Owns every agent slice and competes with the server's unit or scope under app.slice. +const AGENTS_PARENT_SLICE = "app-t3code.slice"; +// Low weights yield CPU and disk to the server under contention, where the user manager +// has those controllers: current systemd delegates cpu, rarely io. TasksMax keeps agents +// below logind's 33% per-user task limit, leaving forks for the server. +const AGENTS_PARENT_PROPERTIES = ["CPUWeight=10", "IOWeight=10", "TasksMax=25%"]; export function ownedAgentSlice(stateDir: string): string { const hash = NodeCrypto.createHash("sha256").update(stateDir).digest("hex").slice(0, 12); @@ -173,20 +179,28 @@ export const make = Effect.gen(function* () { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } + const setProperties = Effect.fn("agentScope.setProperties")(function* ( + slice: string, + properties: ReadonlyArray, + ) { + const result = yield* run(systemctl, [ + "--user", + "set-property", + "--runtime", + slice, + ...properties, + ]); + if (result._tag === "None" || result.value.code !== 0) { + yield* Effect.logWarning("Could not set resource limits on an agents slice", { + slice, + properties, + stderr: result._tag === "Some" ? result.value.stderr.trim() : undefined, + }); + } + }); const limits = agentSliceMemoryLimits(NodeOS.totalmem()); - const limited = yield* run(systemctl, [ - "--user", - "set-property", - "--runtime", - AGENT_SLICE, - `MemoryHigh=${limits.high}`, - `MemoryMax=${limits.max}`, - ]); - if (limited._tag === "None" || limited.value.code !== 0) { - yield* Effect.logWarning("Could not set memory limits on the agents slice", { - stderr: limited._tag === "Some" ? limited.value.stderr.trim() : undefined, - }); - } + yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]); + yield* setProperties(AGENTS_PARENT_SLICE, AGENTS_PARENT_PROPERTIES); yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, ...limits, @@ -201,7 +215,8 @@ export const make = Effect.gen(function* () { }), ); - const scopeByThread = new Map(); + // `shared` scopes serve other threads too: tracked only for OOM diagnosis on old systemd. + const scopeByThread = new Map(); const readScope = Effect.fn("agentScope.readScope")(function* (systemctl: string, unit: string) { const shown = yield* run(systemctl, [ @@ -263,10 +278,11 @@ export const make = Effect.gen(function* () { systemctl: string, threadId: string, unit: string | undefined, + shared = false, ) { const previous = scopeByThread.get(threadId); scopeByThread.delete(threadId); - if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false }); + if (unit !== undefined) scopeByThread.set(threadId, { unit, oomKilled: false, shared }); if (previous !== undefined) yield* forget(systemctl, previous.unit); if (scopeByThread.size > MAX_TRACKED_THREADS) yield* evictOldest(systemctl); }); @@ -326,6 +342,40 @@ export const make = Effect.gen(function* () { } }); + const kill: AgentScopeShape["kill"] = Effect.fn("AgentScope.kill")(function* (threadId) { + const tracked = scopeByThread.get(threadId); + if (tracked === undefined || tracked.shared) return false; + const scope = yield* systemd; + if (scope === undefined) return false; + const shown = yield* run(scope.systemctl, [ + "--user", + "show", + tracked.unit, + "--property=ActiveState,ControlGroup", + ]); + if (shown._tag === "None" || shown.value.code !== 0) return false; + const values = parseKeyValues(shown.value.stdout); + if (values.get("ActiveState") !== "active") return false; + const controlGroup = values.get("ControlGroup") ?? ""; + // cgroup.kill (Linux 5.14+) kills the whole tree at once; systemd's kill is the fallback. + const killedByCgroup = + controlGroup.length > 0 && + (yield* fileSystem + .writeFileString(path.join("/sys/fs/cgroup", controlGroup, "cgroup.kill"), "1") + .pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + )); + if (killedByCgroup) return true; + const killed = yield* run(scope.systemctl, [ + "--user", + "kill", + "--signal=SIGKILL", + tracked.unit, + ]); + return killed._tag === "Some" && killed.value.code === 0; + }); + const clear: AgentScopeShape["clear"] = Effect.fn("agentScope.clear")(function* (threadId) { if (!scopeByThread.has(threadId)) return; const scope = yield* systemd; @@ -351,7 +401,8 @@ export const make = Effect.gen(function* () { // Clear the launch thread's previous scope instead. const continuing = shared === true && scope.supportsOomPolicy; const owned = threadId !== undefined && !continuing; - if (threadId !== undefined) yield* track(scope.systemctl, threadId, owned ? unit : undefined); + if (threadId !== undefined) + yield* track(scope.systemctl, threadId, owned ? unit : undefined, shared === true); const systemdScope: AgentSystemdScope = { systemdRun: scope.systemdRun, runtimeDir: scope.runtimeDir, @@ -371,6 +422,7 @@ export const make = Effect.gen(function* () { }), oomKilled, stop, + kill, clear, } satisfies AgentScopeShape; }); diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 7392e4248b657..42acda7269262 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -11,11 +11,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"; @@ -156,6 +159,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; @@ -354,6 +430,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 e2489a4ccfebe..1c0b2c4693c28 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -32,6 +32,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"; @@ -725,6 +726,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, @@ -734,7 +785,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, }); @@ -788,6 +839,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/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 4854b20aa9c5b..70a65a2dcad1f 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1140,9 +1140,11 @@ export const make = Effect.gen(function* () { const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const configuredHome = instance.environment?.findLast( + (variable) => variable.name === homeVariable, + )?.value; const environmentHome = - instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? - hostEnvironment[homeVariable]; + typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; let homePath: string; if (source === "claudeAgent") { 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/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 c6809c3577132..db7d22a45e0cd 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/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 0bed5b25868cb..8e29708e88974 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -67,7 +67,6 @@ import { enrichProviderSnapshotWithVersionAdvisory, makeCachedProviderMaintenanceResolution, makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "@t3tools/provider-core/server/maintenanceResolver"; import { @@ -92,20 +91,36 @@ import { makeSubscriptionLimitPolicy } from "@t3tools/provider-core/server/Subsc 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/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index a9ebb05a2e1e7..6f457f4f4cf2f 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -32,9 +32,9 @@ function claudeLine(overrides: { describe("parseClaudeLine", () => { it("extracts token totals and a dedupe key", () => { - const record = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); + const [record] = parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text" })); - expect(record).not.toBeNull(); + expect(record).toBeDefined(); expect(record?.provider).toBe("claude"); expect(record?.model).toBe("claude-fable-5"); expect(record?.totals).toEqual({ @@ -50,8 +50,8 @@ describe("parseClaudeLine", () => { it("gives every content block of one message the same dedupe key", () => { // Content-block records repeat full parent usage; summing them overcounts. - const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); - const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); + const [text] = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); + const [toolUse] = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); expect(text?.dedupeKey).toBe(toolUse?.dedupeKey); expect(text?.totals).toEqual(toolUse?.totals); @@ -86,3 +86,133 @@ describe("parseClaudeLine", () => { expect(claudeUsageFormat.parseProjected(synthetic, undefined)).toEqual([]); }); }); + +describe("Claude advisor iterations", () => { + const advisorTurn = { + type: "assistant", + timestamp: "2026-10-22T01:31:11.984Z", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + requestId: "req_1", + message: { + id: "msg_1", + model: "claude-opus-5-5", + usage: { + input_tokens: 4, + cache_read_input_tokens: 118320, + cache_creation_input_tokens: 2676, + output_tokens: 4506, + speed: "standard", + iterations: [ + { + type: "message", + input_tokens: 2, + cache_read_input_tokens: 58352, + cache_creation_input_tokens: 1616, + output_tokens: 141, + }, + { + type: "advisor_message", + model: "claude-fable-5-1", + input_tokens: 54204, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + output_tokens: 4229, + }, + { + type: "message", + input_tokens: 2, + cache_read_input_tokens: 59968, + cache_creation_input_tokens: 1060, + output_tokens: 4365, + }, + ], + }, + }, + }; + const withIterations = (iterations: readonly unknown[]) => ({ + ...advisorTurn, + message: { ...advisorTurn.message, usage: { ...advisorTurn.message.usage, iterations } }, + }); + const parse = (turn: unknown) => claudeUsageFormat.parseLine(JSON.stringify(turn), undefined); + + it("reports the advisor model's tokens as their own record beside the unchanged executor record", () => { + const executor = { + provider: "claude", + timestampMs: Date.parse("2026-10-22T01:31:11.984Z"), + model: "claude-opus-5-5", + sessionId: "5a128faa-8253-489e-b935-6c08e8e670c0", + totals: { + uncachedInputTokens: 4, + cachedInputTokens: 118320, + cacheCreationTokens: 2676, + outputTokens: 4506, + reasoningTokens: 0, + }, + reportedCostUsd: null, + speed: "standard", + dedupeKey: "msg_1:req_1", + }; + const expected = [ + executor, + { + ...executor, + model: "claude-fable-5-1", + totals: { + uncachedInputTokens: 54204, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 4229, + reasoningTokens: 0, + }, + dedupeKey: expect.any(String), + }, + ]; + + expect(parse(advisorTurn)).toEqual(expected); + expect(claudeUsageFormat.parseProjected(advisorTurn, undefined)).toEqual(expected); + }); + + it("keys the advisor record apart from the executor and the same across a message's content blocks", () => { + const block = (type: string) => + parse({ ...advisorTurn, message: { ...advisorTurn.message, content: [{ type }] } }); + const [executorKey, advisorKey] = block("text").map((record) => record.dedupeKey); + + expect(advisorKey).toEqual(expect.any(String)); + expect(advisorKey).not.toBe(executorKey); + expect(block("tool_use").map((record) => record.dedupeKey)).toEqual([executorKey, advisorKey]); + + const unkeyed = parse({ + ...advisorTurn, + requestId: undefined, + message: { ...advisorTurn.message, id: undefined }, + }); + expect(unkeyed.map((record) => record.dedupeKey)).toEqual([null, null]); + }); + + it("emits only the executor record without an advisor iteration", () => { + const executorIterations = advisorTurn.message.usage.iterations.filter( + (iteration) => iteration.type === "message", + ); + + expect(parse(withIterations(executorIterations))).toHaveLength(1); + expect(claudeUsageFormat.parseProjected(withIterations([]), undefined)).toHaveLength(1); + expect( + claudeUsageFormat.parseLine( + claudeLine({ messageId: "msg_3", contentType: "text" }), + undefined, + ), + ).toHaveLength(1); + }); + + it("ignores advisor iterations without a usable model and malformed entries", () => { + const unusable = withIterations([ + null, + "advisor_message", + { type: "advisor_message", input_tokens: 5 }, + { type: "advisor_message", model: "", input_tokens: 5 }, + { type: "advisor_message", model: "", input_tokens: 5 }, + ]); + + expect(parse(unusable).map((record) => record.model)).toEqual(["claude-opus-5-5"]); + }); +}); diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index c5ff71a5c839e..aca7696d31df5 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -20,35 +20,35 @@ export const CLAUDE_SYNTHETIC_MODEL = ""; * the same complete `usage` object for the parent message. Summing them overcounts by roughly * 2.4x on a real workload, so the caller must drop repeats by `dedupeKey` and keep the first. */ -export function parseClaudeLine(line: string): UsageRecord | null { +export function parseClaudeLine(line: string): readonly UsageRecord[] { let parsed: unknown; try { parsed = JSON.parse(line); } catch { - return null; + return []; } return parseClaudeRecord(parsed); } -function parseClaudeRecord(parsed: unknown): UsageRecord | null { - if (typeof parsed !== "object" || parsed === null) return null; +function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] { + if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; - if (record["type"] !== "assistant") return null; + if (record["type"] !== "assistant") return []; const message = record["message"]; - if (typeof message !== "object" || message === null) return null; + if (typeof message !== "object" || message === null) return []; const messageRecord = message as Record; const usage = messageRecord["usage"]; - if (typeof usage !== "object" || usage === null) return null; + if (typeof usage !== "object" || usage === null) return []; const usageRecord = usage as Record; const timestampMs = parseTimestampMs(record["timestamp"]); - if (timestampMs === null) return null; + if (timestampMs === null) return []; const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : ""; - if (model.length === 0 || model === CLAUDE_SYNTHETIC_MODEL) return null; + if (model.length === 0 || model === CLAUDE_SYNTHETIC_MODEL) return []; const messageId = typeof messageRecord["id"] === "string" ? messageRecord["id"] : null; const requestId = typeof record["requestId"] === "string" ? record["requestId"] : null; @@ -57,7 +57,7 @@ function parseClaudeRecord(parsed: unknown): UsageRecord | null { const cost = record["costUSD"]; - return { + const executor: UsageRecord = { provider: "claude", timestampMs, model, @@ -74,10 +74,40 @@ function parseClaudeRecord(parsed: unknown): UsageRecord | null { speed: usageRecord["speed"] === "fast" ? "fast" : "standard", dedupeKey, }; -} -const orEmpty = (record: UsageRecord | null): readonly UsageRecord[] => - record === null ? [] : [record]; + // Top-level `usage` is the executor's alone. Advisor calls run on another model at its own rates + // and appear only in `iterations`, so each one is its own record under that model. + const records = [executor]; + const iterations: readonly unknown[] = Array.isArray(usageRecord["iterations"]) + ? usageRecord["iterations"] + : []; + for (const [index, iteration] of iterations.entries()) { + if (typeof iteration !== "object" || iteration === null) continue; + const advisor = iteration as Record; + if (advisor["type"] !== "advisor_message") continue; + + const advisorModel = typeof advisor["model"] === "string" ? advisor["model"] : ""; + if (advisorModel.length === 0 || advisorModel === CLAUDE_SYNTHETIC_MODEL) continue; + + records.push({ + provider: "claude", + timestampMs, + model: advisorModel, + sessionId: executor.sessionId, + totals: { + uncachedInputTokens: tokenCount(advisor["input_tokens"]), + cachedInputTokens: tokenCount(advisor["cache_read_input_tokens"]), + cacheCreationTokens: tokenCount(advisor["cache_creation_input_tokens"]), + outputTokens: tokenCount(advisor["output_tokens"]), + reasoningTokens: 0, + }, + reportedCostUsd: null, + speed: executor.speed, + dedupeKey: dedupeKey === null ? null : `${dedupeKey}:advisor:${index}`, + }); + } + return records; +} export const claudeUsageFormat: TranscriptUsageFormat = { selectFields: { @@ -89,8 +119,8 @@ export const claudeUsageFormat: TranscriptUsageFormat = { message: { id: true, model: true, usage: true }, }, usageMarkers: ['"usage"'], - parseLine: (line) => orEmpty(parseClaudeLine(line)), - parseProjected: (projected) => orEmpty(parseClaudeRecord(projected)), + parseLine: (line) => parseClaudeLine(line), + parseProjected: (projected) => parseClaudeRecord(projected), }; export const claudeUsageReader: ProviderUsageReader = { diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index fd28a457d46b1..3991081d3f8b8 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -14,7 +14,10 @@ import { type DevinSettings, ProviderDriverKind, type ProviderInstanceConfigMap, + type ResolvedProviderInstanceEnvironment, ProviderInstanceId, + SecretProviderId, + type SecretReference, } from "@t3tools/contracts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -31,10 +34,13 @@ import { DevinDriver, type DevinDriverEnv } from "./Drivers/DevinDriver.ts"; import { CustomAcpDriver, type CustomAcpDriverEnv } from "./Drivers/CustomAcpDriver.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; @@ -50,6 +56,8 @@ import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; +import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -219,6 +227,7 @@ describe("ProviderInstanceRegistry โ€” multi-instance codex slice", () => { }), ), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge(Layer.mock(SecretResolver.SecretResolver)({})), Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed( @@ -847,6 +856,7 @@ describe("ProviderInstanceRegistry โ€” all drivers slice", () => { }), ), Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.mock(SecretResolver.SecretResolver)({})), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", @@ -1080,3 +1090,169 @@ describe("ProviderInstanceRegistry โ€” all drivers slice", () => { }).pipe(Effect.provide(layerTest)), ); }); + +const DisabledByDefaultConfig = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), +}); + +describe("ProviderInstanceRegistry secret environment", () => { + const driverKind = ProviderDriverKind.make("fake"); + const instanceId = ProviderInstanceId.make("fake_main"); + const secret: SecretReference = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/OpenAI/credential", + }; + + function setup( + initial: Record, + configSchema: AnyProviderDriver["configSchema"] = Schema.Unknown, + ) { + const vault = new Map(Object.entries(initial)); + const reads: Array> = []; + const invalidated: Array> = []; + const created: Array = []; + const driver: AnyProviderDriver = { + driverKind, + metadata: { displayName: "Fake" }, + configSchema, + defaultConfig: () => ({}), + create: (input) => + Effect.sync(() => { + created.push(input.environment); + return { instanceId: input.instanceId, enabled: input.enabled } as ProviderInstance; + }), + }; + const gate = Deferred.makeUnsafe(); + const resolver = Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => + Deferred.await(gate).pipe( + Effect.andThen( + Effect.sync(() => { + reads.push(references.map((reference) => reference.reference)); + return references.map((reference) => vault.get(reference.reference)); + }), + ), + ), + invalidate: (references = []) => + Effect.sync(() => { + invalidated.push(references.map((reference) => reference.reference)); + }), + }); + const configMap: ProviderInstanceConfigMap = { + [instanceId]: { + driver: driverKind, + config: {}, + environment: [ + { name: "OPENAI_API_KEY", value: secret, sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ], + }, + }; + const makeGated = makeProviderInstanceRegistry({ drivers: [driver], configMap }).pipe( + Effect.provide(resolver), + ); + // Secrets are read after startup, so wait for the change that applies them. + const make = Effect.gen(function* () { + const built = yield* makeGated; + const changes = yield* built.registry.subscribeChanges; + yield* Deferred.succeed(gate, undefined); + yield* PubSub.take(changes); + return built; + }); + return { vault, reads, invalidated, created, make, makeGated, gate }; + } + + it.effect("starts an instance with the secret its environment points at", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry } = yield* fixture.make; + + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(fixture.created).toEqual([ + [ + { name: "OPENAI_API_KEY", value: "sk-live", sensitive: true }, + { name: "BASE_URL", value: "https://example.test", sensitive: false }, + ], + ]); + }).pipe(Effect.scoped); + }); + + it.effect("marks an instance unavailable and names the variable it could not read", () => { + const fixture = setup({}); + return Effect.gen(function* () { + const { registry } = yield* fixture.make; + + expect(yield* registry.getInstance(instanceId)).toBeUndefined(); + expect(fixture.created).toEqual([]); + const [unavailable] = yield* registry.listUnavailable; + expect(unavailable?.instanceId).toBe(instanceId); + expect(unavailable?.unavailableReason).toContain("OPENAI_API_KEY"); + }).pipe(Effect.scoped); + }); + + it.effect("does not read the secrets of an instance its config decodes as disabled", () => { + const fixture = setup({ [secret.reference]: "sk-live" }, DisabledByDefaultConfig); + return Effect.gen(function* () { + const { mutator } = yield* fixture.makeGated; + yield* mutator.reloadSecrets(); + + expect(fixture.reads).toEqual([]); + expect(fixture.invalidated).toEqual([]); + expect(fixture.created).toHaveLength(1); + }).pipe(Effect.scoped); + }); + + it.effect("re-reads secrets on reload and rebuilds only when they changed", () => { + const fixture = setup({}); + return Effect.gen(function* () { + const { registry, mutator } = yield* fixture.make; + + fixture.vault.set(secret.reference, "sk-live"); + yield* mutator.reloadSecrets(); + expect(fixture.invalidated).toEqual([[secret.reference]]); + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + + yield* mutator.reloadSecrets(instanceId); + expect(fixture.created).toHaveLength(1); + + fixture.vault.set(secret.reference, "sk-rotated"); + yield* mutator.reloadSecrets(instanceId); + expect(fixture.created.map((environment) => environment[0]?.value)).toEqual([ + "sk-live", + "sk-rotated", + ]); + }).pipe(Effect.scoped); + }); + + it.effect("keeps a working instance live when a reload cannot read its secret", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry, mutator } = yield* fixture.make; + + fixture.vault.delete(secret.reference); + yield* mutator.reloadSecrets(); + + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + expect(fixture.created).toHaveLength(1); + }).pipe(Effect.scoped); + }); + + it.effect("starts without waiting for secrets to be read", () => { + const fixture = setup({ [secret.reference]: "sk-live" }); + return Effect.gen(function* () { + const { registry } = yield* fixture.makeGated; + const changes = yield* registry.subscribeChanges; + + expect(yield* registry.getInstance(instanceId)).toBeUndefined(); + const [pending] = yield* registry.listUnavailable; + expect(pending?.unavailableReason).toBe("Reading secretsโ€ฆ"); + + yield* Deferred.succeed(fixture.gate, undefined); + yield* PubSub.take(changes); + expect(yield* registry.getInstance(instanceId)).toBeDefined(); + expect(yield* registry.listUnavailable).toEqual([]); + }).pipe(Effect.scoped); + }); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 3bbbe2900b9f2..70352c5ef8593 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -3,7 +3,10 @@ import { ProviderInstanceId, type ProviderInstanceConfig, type ProviderInstanceConfigMap, + type ProviderInstanceEnvironment, type ProviderDriverKind, + type ResolvedProviderInstanceEnvironment, + type SecretReference, type ServerProvider, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -11,12 +14,16 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; + import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver"; @@ -39,6 +46,7 @@ interface LiveEntry { readonly instance: ProviderInstance; readonly scope: Scope.Closeable; readonly entry: ProviderInstanceConfig; + readonly environment: ResolvedProviderInstanceEnvironment; } interface RegistryState { @@ -50,6 +58,39 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); +const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array => + environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value])); + +const secretReferencesOf = (entry: ProviderInstanceConfig): ReadonlyArray => + secretReferencesIn(entry.environment ?? []); + +const withoutSecretReferences = ( + environment: ProviderInstanceEnvironment, +): ResolvedProviderInstanceEnvironment => + environment.flatMap((variable) => + typeof variable.value === "string" ? [{ ...variable, value: variable.value }] : [], + ); + +const readSecretEnvironment = Effect.fn("ProviderInstanceRegistry.readSecretEnvironment")( + function* (environment: ProviderInstanceEnvironment) { + const references = secretReferencesIn(environment); + const values = + references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references); + const unreadable: Array = []; + let index = 0; + const resolved: ResolvedProviderInstanceEnvironment = environment.flatMap((variable) => { + if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + const secret = values[index++]; + if (secret === undefined) { + unreadable.push(variable.name); + return []; + } + return [{ ...variable, value: secret, sensitive: true }]; + }); + return { environment: resolved, unreadable }; + }, +); + /** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); @@ -59,19 +100,32 @@ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true; }; +// The enabled state `buildEntry` decodes, so a disabled instance never reads its secrets. +const isEntryEnabled = Effect.fn("ProviderInstanceRegistry.isEntryEnabled")(function* ( + driversById: ReadonlyMap>, + entry: ProviderInstanceConfig, +) { + const driver = driversById.get(entry.driver); + if (!driver) return false; + const decoder = Schema.decodeUnknownEffect(driver.configSchema); + const decoded = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.option); + return Option.isSome(decoded) && resolveEntryEnabled(entry, decoded.value); +}); + const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; readonly instanceId: ProviderInstanceId; readonly rawInstanceId: string; readonly entry: ProviderInstanceConfig; + readonly deferSecrets: boolean; }): Effect.fn.Return< | { readonly kind: "live"; readonly live: LiveEntry } | { readonly kind: "unavailable"; readonly snapshot: ServerProvider }, never, - R + R | SecretResolver.SecretResolver > { - const { driversById, parentScope, instanceId, rawInstanceId, entry } = input; + const { driversById, parentScope, instanceId, rawInstanceId, entry, deferSecrets } = input; const driver = driversById.get(entry.driver); if (!driver) { return { @@ -109,6 +163,37 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* 0) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: "Reading secretsโ€ฆ", + }), + }; + } + const { environment, unreadable } = enabled + ? yield* readSecretEnvironment(entry.environment ?? []) + : { environment: withoutSecretReferences(entry.environment ?? []), unreadable: [] }; + // Starting with a blank credential would fail later with a less useful error, or fall back to + // whatever the server inherited. + if (unreadable.length > 0) { + return { + kind: "unavailable" as const, + snapshot: yield* buildUnavailableProviderSnapshot({ + driverKind: entry.driver, + instanceId, + displayName: entry.displayName, + accentColor: entry.accentColor, + reason: `Could not read ${unreadable.join(", ")} from its secret provider. Unlock it, then refresh.`, + }), + }; + } + const childScope = yield* Scope.make(); yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore)); @@ -117,8 +202,8 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* (input: { readonly state: RegistryState; readonly driversById: ReadonlyMap>; readonly parentScope: Scope.Scope; -}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect) => { +}): (( + configMap: ProviderInstanceConfigMap, + rebuild?: ReadonlySet, + deferSecrets?: boolean, +) => Effect.Effect) => { const { state, driversById, parentScope } = input; - return (configMap: ProviderInstanceConfigMap) => + return (configMap, rebuild = new Set(), deferSecrets = false) => Effect.gen(function* () { const previousEntries = yield* Ref.get(state.entries); const previousUnavailable = yield* Ref.get(state.unavailable); @@ -175,7 +265,10 @@ const makeReconcile = (input: { continue; } const nextEntry = configMap[instanceId]; - if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) { + if ( + rebuild.has(instanceId) || + (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) + ) { replacedIds.add(instanceId); } } @@ -186,6 +279,20 @@ const makeReconcile = (input: { } } + // Read every secret the builds below need in one go, so a vault unlocks once. + const references = yield* Effect.forEach(nextRaw, ([rawInstanceId, entry]) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make(rawInstanceId); + const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId); + return kept || !(yield* isEntryEnabled(driversById, entry)) + ? [] + : secretReferencesOf(entry); + }), + ).pipe(Effect.map((perEntry) => perEntry.flat())); + if (references.length > 0 && !deferSecrets) { + yield* (yield* SecretResolver.SecretResolver).read(references); + } + const builtEntries = new Map(); const builtUnavailable = new Map(); let orderChanged = false; @@ -208,6 +315,7 @@ const makeReconcile = (input: { instanceId, rawInstanceId, entry, + deferSecrets, }); if (result.kind === "live") { builtEntries.set(instanceId, result.live); @@ -260,7 +368,7 @@ export const makeProviderInstanceRegistry = Effect.fn( readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape; }, never, - R | Scope.Scope + R | SecretResolver.SecretResolver | Scope.Scope > { const driversById = new Map>( input.drivers.map((driver) => [driver.driverKind, driver]), @@ -272,7 +380,7 @@ export const makeProviderInstanceRegistry = Effect.fn( // scopes to the *caller's* scope instead of the registry's. const parentScope = yield* Scope.Scope; - const driverContext = yield* Effect.context(); + const driverContext = yield* Effect.context(); const entries = yield* Ref.make>(new Map()); const unavailable = yield* Ref.make>(new Map()); @@ -281,10 +389,76 @@ export const makeProviderInstanceRegistry = Effect.fn( const state: RegistryState = { entries, unavailable, changes }; const reconcileWithR = makeReconcile({ state, driversById, parentScope }); + const configMapRef = yield* Ref.make(input.configMap); + const semaphore = yield* Semaphore.make(1); const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] = - (configMap) => reconcileWithR(configMap).pipe(Effect.provideContext(driverContext)); + (configMap) => + semaphore.withPermits(1)( + Ref.set(configMapRef, configMap).pipe( + Effect.andThen(reconcileWithR(configMap)), + Effect.provideContext(driverContext), + ), + ); - yield* reconcile(input.configMap); + // Rebuilds an instance only when it was unavailable or its secrets changed, so a refresh does + // not restart providers whose credentials are the same. + const reloadSecrets: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reloadSecrets"] = + Effect.fn("ProviderInstanceRegistry.reloadSecrets")( + function* (instanceId) { + const configMap = yield* Ref.get(configMapRef); + const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) => + (instanceId === undefined || rawInstanceId === instanceId) && + secretReferencesOf(entry).length > 0 + ? isEntryEnabled(driversById, entry) + : Effect.succeed(false), + ); + if (targets.length === 0) return; + const resolver = yield* SecretResolver.SecretResolver; + const references = targets.flatMap(([, entry]) => secretReferencesOf(entry)); + yield* resolver.invalidate(references); + yield* resolver.read(references); + const live = yield* Ref.get(entries); + const rebuild = new Set(); + for (const [rawInstanceId, entry] of targets) { + const current = live.get(ProviderInstanceId.make(rawInstanceId)); + if (current === undefined) continue; + const { environment, unreadable } = yield* readSecretEnvironment(entry.environment ?? []); + // A failed re-read keeps the working instance and the value it already holds. + if (unreadable.length === 0 && !Equal.equals(environment, current.environment)) { + rebuild.add(current.instance.instanceId); + } + } + yield* reconcileWithR(configMap, rebuild); + }, + (effect) => semaphore.withPermits(1)(effect), + Effect.provideContext(driverContext), + ); + + // Reading secrets can wait on a vault unlock, so the server does not wait for it to start. + yield* semaphore.withPermits(1)( + Ref.set(configMapRef, input.configMap).pipe( + Effect.andThen(reconcileWithR(input.configMap, new Set(), true)), + Effect.provideContext(driverContext), + ), + ); + const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) => + secretReferencesOf(entry).length > 0 + ? isEntryEnabled(driversById, entry) + : Effect.succeed(false), + ).pipe(Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(entry)))); + if (startupReferences.length > 0) { + // The read waits outside the lock so other mutations are not held for a vault prompt; a + // configuration saved meanwhile has already reconciled itself. + yield* Effect.gen(function* () { + yield* (yield* SecretResolver.SecretResolver).read(startupReferences); + yield* semaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(configMapRef); + if (current === input.configMap) yield* reconcileWithR(current); + }), + ); + }).pipe(Effect.provideContext(driverContext), Effect.forkScoped); + } const registry: ProviderInstanceRegistry["Service"] = { getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)), @@ -307,6 +481,7 @@ export const makeProviderInstanceRegistry = Effect.fn( const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = { reconcile, + reloadSecrets, }; return { registry, mutator }; @@ -318,7 +493,7 @@ export const layer = (input: { }): Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, - R + R | SecretResolver.SecretResolver > => Layer.effectContext( makeProviderInstanceRegistry(input).pipe( @@ -331,5 +506,5 @@ export const layer = (input: { ) as Layer.Layer< ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, - R + R | SecretResolver.SecretResolver >; diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index cb778f1638a22..c8f2fb9b33e05 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -9,6 +9,7 @@ import { ServerSettings, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -16,6 +17,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as Settings from "../serverSettings.ts"; +import * as SecretResolver from "../secrets/SecretResolver.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "./builtInDrivers.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; @@ -39,6 +41,8 @@ type ProviderInstanceRegistryHydrationEnv = | ServerConfig.ServerConfig | Path.Path | FileSystem.FileSystem + // Secret provider reads. + | Crypto.Crypto // The Claude runner counts its CLIs as running agents for port discovery. | PortScanner.PortDiscovery; @@ -141,7 +145,8 @@ const layerSettingsWatcher = (settingsChanges: Stream.Stream) => ); export const layer: Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, + | ProviderInstanceRegistry.ProviderInstanceRegistry + | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, ProviderInstanceRegistryHydrationEnv > = Layer.unwrap( @@ -167,12 +172,14 @@ export const layer: Layer.Layer< Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer), Layer.provide(AcpRegistrySupport.layerFromHost), Layer.provide(ProviderHostLive.layer), + Layer.provide(SecretResolver.layer), ); return layerSettingsWatcher(settingsChanges).pipe(Layer.provideMerge(layerMutable)); }), ) as Layer.Layer< - ProviderInstanceRegistry.ProviderInstanceRegistry, + | ProviderInstanceRegistry.ProviderInstanceRegistry + | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, never, ProviderInstanceRegistryHydrationEnv >; diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index 51322826230d8..35eb52d3f2906 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,10 +1,12 @@ /** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ -import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; +import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; export interface ProviderInstanceRegistryMutatorShape { readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect; + /** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */ + readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect; } export class ProviderInstanceRegistryMutator extends Context.Service< diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index ad1548d66d130..3003af28c201f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -82,6 +82,7 @@ import type { ProviderWorkspaceSnapshot, } from "@t3tools/provider-core/server/driver"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; @@ -117,6 +118,10 @@ const withBundledCompatibility = (snapshot: ServerProvider) => ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, ); +const layerUnusedMutator = Layer.mock( + ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, +)({}); + const layerTestHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -1134,7 +1139,12 @@ it.layer( retainedModels, ); }).pipe( - Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerInstanceRegistry), + Layer.provide(layerUnusedMutator), + ), + ), Effect.scoped, ); } @@ -1265,7 +1275,12 @@ it.layer( retainedModels, ); }).pipe( - Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerInstanceRegistry))), + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerInstanceRegistry), + Layer.provide(layerUnusedMutator), + ), + ), Effect.scoped, ); } @@ -1537,6 +1552,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1632,6 +1648,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1752,6 +1769,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(instanceRegistryLayer), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1858,6 +1876,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -1986,6 +2005,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -2089,6 +2109,7 @@ it.layer( yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const runtimeServices = yield* Layer.build( ProviderRegistry.layer.pipe( + Layer.provide(layerUnusedMutator), Layer.provideMerge(layerInstanceRegistry), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -2260,7 +2281,7 @@ it.layer( enabled: false, config: { arbitrary: "payload" }, }, - } as unknown as ContractServerSettings["providerInstances"], + } as unknown as (typeof ServerSettings.Encoded)["providerInstances"], }), ), ); @@ -2465,7 +2486,10 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providerInstances: disabledDefaultSlots("codex", "grok"), + providerInstances: encodeServerSettings({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: disabledDefaultSlots("codex", "grok"), + }).providerInstances, }), ), ); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index c80ac198ccefb..bb2c1e1dd4459 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -19,6 +19,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -27,6 +28,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; import * as ServerConfig from "../config.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -61,6 +63,11 @@ export class ProviderRegistry extends Context.Service< instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Re-reads secret-backed environments first, so a fixed secret applies without a restart. */ + readonly reloadSecretsAndRefresh: ( + instanceId?: ProviderInstanceId, + ) => Effect.Effect>; + /** Only refreshed instances can verify an update; the published list still includes every row. */ readonly refreshAfterUpdate: ( provider: ProviderDriverKind, @@ -355,6 +362,8 @@ export const layer = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const instanceRegistryMutator = + yield* ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator; const manifestService = yield* ModelManifest.ModelManifest; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const serviceScope = yield* Effect.scope; @@ -639,7 +648,9 @@ export const layer = Layer.effect( ); if (claimed.owner) { // Detach shared passes so the first caller's interruption cannot cancel other callers' work. - yield* getLiveSources.pipe( + // Syncing first keeps a refresh right after a rebuild from probing the replaced instance. + yield* syncLiveSourcesAndContinue.pipe( + Effect.andThen(getLiveSources), Effect.flatMap((sources) => Effect.forEach(sources, (source) => refreshOneSource(source), { concurrency: "unbounded", @@ -676,6 +687,7 @@ export const layer = Layer.effect( const refreshInstance = Effect.fn("refreshInstance")(function* ( instanceId: ProviderInstanceId, ) { + yield* syncLiveSourcesAndContinue; const sources = yield* getLiveSources; const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); if (!providerSource) { @@ -684,6 +696,13 @@ export const layer = Layer.effect( return yield* refreshOneSource(providerSource); }); + const reloadSecretsAndRefresh = Effect.fn("reloadSecretsAndRefresh")(function* ( + instanceId?: ProviderInstanceId, + ) { + yield* instanceRegistryMutator.reloadSecrets(instanceId); + return yield* instanceId === undefined ? refresh() : refreshInstance(instanceId); + }); + const refreshAfterUpdate = Effect.fn("refreshAfterUpdate")(function* ( provider: ProviderDriverKind, selectedInstanceId: ProviderInstanceId, @@ -883,6 +902,7 @@ export const layer = Layer.effect( }, ); }), + Scope.provide(serviceScope), ); yield* upsertProviders(fallbackProviders, { publish: false }); @@ -1033,6 +1053,8 @@ export const layer = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + reloadSecretsAndRefresh: (instanceId?: ProviderInstanceId) => + reloadSecretsAndRefresh(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), refreshWorkspaceSnapshot: (input) => refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), refreshAfterUpdate, 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 e414a5fc68a1e..89475c8bb402f 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 6040, "codexAdditionalContextBytes": 7770, - "mcpToolListJsonBytes": 76332 + "mcpToolListJsonBytes": 76432 } 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 9aaa195bb7752..bb779fe242910 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 bd2be53a8ac2f..cb808551f4393 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -13,6 +13,7 @@ import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; +import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import type { ProviderInstance } from "@t3tools/provider-core/server/driver"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; @@ -22,7 +23,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, @@ -263,6 +269,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"], @@ -453,7 +472,16 @@ it.effect("a remote policy refresh preserves a newer health result on the regist assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); assert.strictEqual(updated?.status, "error"); assert.strictEqual(updated?.message, "Authentication failed"); - }).pipe(Effect.provide(ProviderRegistry.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe( + Effect.provide( + ProviderRegistry.layer.pipe( + Layer.provide(layerDependencies), + Layer.provide( + Layer.mock(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator)({}), + ), + ), + ), + ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 75c592b7811a1..943928c4e18cf 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -192,6 +192,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + reloadSecretsAndRefresh: () => Ref.get(providersRef), refreshAfterUpdate: () => Ref.get(providersRef), refreshWorkspaceSnapshot: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 358d49afe45c2..a4008acc18efb 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,7 @@ const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + reloadSecretsAndRefresh: () => Effect.succeed(providers), refreshAfterUpdate: () => Effect.succeed(providers), refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index b256757496213..d68473fba8e19 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2221,6 +2221,98 @@ it.effect("does not revive old summaries when project epochs are evicted", () => }), ); +it.effect("a refresh or a finished turn drops the provider's own change request reads", () => + Effect.gen(function* () { + const forgotten: Array<{ readonly repository: string; readonly number?: number } | undefined> = + []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + forgetChangeRequestReads: (scope) => Effect.sync(() => void forgotten.push(scope)), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + yield* service.invalidate({ reference }); + yield* service.refreshAfterTurn(reference.projectId); + yield* service.invalidate({}); + + assert.deepStrictEqual(forgotten, [ + { repository: "acme/web", number: 1 }, + undefined, + undefined, + ]); + }), +); + +it.effect("a read during a refresh cannot keep the provider's old change request", () => + Effect.gen(function* () { + let hostTitle = "old"; + let held: string | undefined; + let gate: + | { readonly entered: Deferred.Deferred; readonly release: Deferred.Deferred } + | undefined; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + getChangeRequest: () => + Effect.sync(() => { + held ??= hostTitle; + return { ...hostedChangeRequest("body"), title: held }; + }), + forgetChangeRequestReads: () => + Effect.gen(function* () { + if (gate !== undefined) { + yield* Deferred.succeed(gate.entered, undefined); + yield* Deferred.await(gate.release); + } + held = undefined; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const refreshWithReadInBetween = (refresh: Effect.Effect) => + Effect.gen(function* () { + gate = { entered: yield* Deferred.make(), release: yield* Deferred.make() }; + const running = yield* refresh.pipe(Effect.forkChild()); + yield* Deferred.await(gate.entered); + yield* service.detail(reference); + yield* Deferred.succeed(gate.release, undefined); + yield* Fiber.join(running); + gate = undefined; + }); + + assert.strictEqual((yield* service.detail(reference)).title, "old"); + hostTitle = "after turn"; + yield* refreshWithReadInBetween(service.refreshAfterTurn(reference.projectId)); + assert.strictEqual((yield* service.detail(reference)).title, "after turn"); + + hostTitle = "after refresh"; + yield* refreshWithReadInBetween(service.invalidate({ reference })); + assert.strictEqual((yield* service.detail(reference)).title, "after refresh"); + }), +); + it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 0d8aa0d818c18..3467efacbc979 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -528,6 +528,9 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.forgetChangeRequestReads === undefined + ? {} + : { forgetChangeRequestReads: api.forgetChangeRequestReads }), ...(api.getChangeRequestPreview === undefined ? {} : { getChangeRequestPreview: wrap("getChangeRequestPreview", api.getChangeRequestPreview) }), @@ -3427,6 +3430,13 @@ export const make = Effect.gen(function* () { return { stats: [...held, ...result.stats] }; }); + const forgetProviderReads = (scope?: { readonly repository: string; readonly number?: number }) => + Effect.forEach( + registry.kinds, + (kind) => registry.get(kind)?.forgetChangeRequestReads?.(scope) ?? Effect.void, + { discard: true }, + ); + const invalidate: PullRequestService["Service"]["invalidate"] = Effect.fn( "PullRequestService.invalidate", )(function* (input, options) { @@ -3442,13 +3452,15 @@ export const make = Effect.gen(function* () { if (reference !== undefined) { yield* canonicalRef(reference).pipe( Effect.flatMap((ref) => - readCache - .invalidate(refScope(ref)) - .pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(ref)))), + forgetProviderReads({ repository: ref.repository, number: ref.number }).pipe( + Effect.andThen(readCache.invalidate(refScope(ref))), + Effect.andThen(Effect.sync(() => bumpRefEpoch(ref))), + ), ), Effect.ignore, ); } else { + yield* forgetProviderReads(); listingsEpoch = ++epochCounter; everyFileRevisionEpoch = ++epochCounter; viewersByHost.clear(); @@ -3459,22 +3471,27 @@ export const make = Effect.gen(function* () { } }); - const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) => - Effect.suspend(() => { - listingsEpoch = ++epochCounter; - projectEpochs.delete(projectId); - if (projectEpochs.size >= REF_EPOCH_CAPACITY) { - const oldest = projectEpochs.keys().next().value; - if (oldest !== undefined) { - projectEpochFloor = projectEpochs.get(oldest)!; - projectEpochs.delete(oldest); - } + const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.fn( + "PullRequestService.refreshAfterTurn", + )(function* (projectId) { + // Everything the provider holds, not just the project's repository: a turn can touch a + // change request reached through another repository, and no epoch may move until those + // reads are gone, or a reader in between keeps the old one under the new epoch. + yield* forgetProviderReads(); + listingsEpoch = ++epochCounter; + projectEpochs.delete(projectId); + if (projectEpochs.size >= REF_EPOCH_CAPACITY) { + const oldest = projectEpochs.keys().next().value; + if (oldest !== undefined) { + projectEpochFloor = projectEpochs.get(oldest)!; + projectEpochs.delete(oldest); } - projectEpochs.set(projectId, listingsEpoch); - return readCache - .invalidate(`project:${projectId}`) - .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch))); - }); + } + projectEpochs.set(projectId, listingsEpoch); + const epoch = listingsEpoch; + yield* readCache.invalidate(`project:${projectId}`); + yield* SubscriptionRef.set(pullRequestRefreshes, epoch); + }); const invalidatedByMutation = ( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 7f0e86bd6cf49..40c741fdd68ac 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, @@ -217,6 +219,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleThread: unused, interruptThread: unused, stopWorkflow: () => Effect.die("unused stopWorkflow"), + forceStopThread: () => Effect.die("unused forceStopThread"), awaitBackgroundTasks: unused, stopDelegatedTasks: unused, getThreadEventSequence: unused, diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index dc5b0a362ee2b..70b7fb0587e14 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -728,10 +728,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/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 8f88b387bd454..39ce1554cea61 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -8,6 +8,8 @@ import * as Path from "effect/Path"; import { VcsRepositoryDetectionError, VcsUnsupportedOperationError, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, type ReviewDiffPreviewError, @@ -20,9 +22,18 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as KnownWorkspaces from "../workspace/KnownWorkspaces.ts"; +const WORKSPACE_BOUND_SUBJECTS = { + "ReviewService.getBranchCommits": "commit list", + "ReviewService.getDiffPreview": "diff preview", + "ReviewService.getDiffFileContents": "diff file contents", +} as const; + export class ReviewService extends Context.Service< ReviewService, { + readonly getBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; @@ -61,7 +72,7 @@ export const make = Effect.gen(function* () { }; const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( - operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", + operation: keyof typeof WORKSPACE_BOUND_SUBJECTS, cwd: string, ) { const [candidate, workspaceRoot] = yield* Effect.all([ @@ -78,13 +89,27 @@ export const make = Effect.gen(function* () { return yield* new VcsRepositoryDetectionError({ operation, cwd, - detail: - operation === "ReviewService.getDiffPreview" - ? "Review diff preview cwd must stay within the configured workspace root." - : "Review diff file contents cwd must stay within the configured workspace root.", + detail: `Review ${WORKSPACE_BOUND_SUBJECTS[operation]} cwd must stay within the configured workspace root.`, }); }); + const getBranchCommits: ReviewService["Service"]["getBranchCommits"] = Effect.fn( + "ReviewService.getBranchCommits", + )(function* (input) { + yield* assertWorkspaceBoundCwd("ReviewService.getBranchCommits", input.cwd); + + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (!handle) return { commits: [], truncated: false }; + if (handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getBranchCommits", + kind: handle.kind, + detail: "Review commit lists require a Git repository.", + }); + } + return yield* git.getReviewBranchCommits(input); + }); + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { @@ -120,6 +145,7 @@ export const make = Effect.gen(function* () { }); return ReviewService.of({ + getBranchCommits, getDiffPreview, getDiffFileContents, }); 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.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index ec4bc7270a57c..1a7ac06b6bc82 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -401,6 +401,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* () { @@ -431,7 +460,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` @@ -1019,6 +1054,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 })), ); @@ -1759,6 +1816,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", { @@ -2154,10 +2213,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 }) => diff --git a/apps/server/src/secrets/SecretResolver.ts b/apps/server/src/secrets/SecretResolver.ts new file mode 100644 index 0000000000000..076e4a0780793 --- /dev/null +++ b/apps/server/src/secrets/SecretResolver.ts @@ -0,0 +1,20 @@ +import * as SecretResolverCore from "@t3tools/secret-provider-core/server/SecretResolver"; +import type { ServerSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { BUILT_IN_SECRET_PROVIDER_DRIVER_LIST } from "./secretProviderDefinitions.ts"; +import * as Settings from "../serverSettings.ts"; + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const serverSettings = yield* Settings.ServerSettingsService; + return SecretResolverCore.layer({ + drivers: BUILT_IN_SECRET_PROVIDER_DRIVER_LIST, + secretProviders: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.secretProviders), + Effect.orElseSucceed((): ServerSettings["secretProviders"] => ({})), + ), + }); + }), +); diff --git a/apps/server/src/secrets/secretProviderDefinitions.test.ts b/apps/server/src/secrets/secretProviderDefinitions.test.ts new file mode 100644 index 0000000000000..b958a480693e1 --- /dev/null +++ b/apps/server/src/secrets/secretProviderDefinitions.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "@effect/vitest"; + +import { + BUILT_IN_SECRET_PROVIDER_DRIVER_LIST, + secretProviderDefinitions, +} from "./secretProviderDefinitions.ts"; + +it("runs a driver for every secret provider the server and clients read, in their order", () => { + expect(BUILT_IN_SECRET_PROVIDER_DRIVER_LIST.map((driver) => driver.definition)).toEqual( + secretProviderDefinitions.definitions, + ); +}); diff --git a/apps/server/src/secrets/secretProviderDefinitions.ts b/apps/server/src/secrets/secretProviderDefinitions.ts new file mode 100644 index 0000000000000..409df40bf16cf --- /dev/null +++ b/apps/server/src/secrets/secretProviderDefinitions.ts @@ -0,0 +1,7 @@ +import { builtInSecretProviderDefinitions } from "@t3tools/secret-provider-definitions/registry"; +import * as OnePasswordDriver from "@t3tools/secret-provider-onepassword/server/driver"; + +/** In the shared registry's order. */ +export const BUILT_IN_SECRET_PROVIDER_DRIVER_LIST = [OnePasswordDriver.driver] as const; + +export const secretProviderDefinitions = builtInSecretProviderDefinitions; 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 59f6b67f04509..549aae79e8be5 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -141,13 +141,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - schemaVersion: 1, - snapshotSequence: 0, - threads: [], - archivedThreads: [], - }), + countThreads: () => Effect.succeed(0), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({}), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 274b149c66efc..bef61bbca56fd 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -176,13 +176,8 @@ it.effect.each([ }), }), Layer.mock(ThreadManagement.ThreadManagementService)({ - getShellSnapshot: () => - Effect.succeed({ - threads: [], - archivedThreads: [], - schemaVersion: 1, - snapshotSequence: 0, - }), + countThreads: () => Effect.succeed(0), + listActiveShellIndex: () => Effect.succeed([]), }), Layer.mock(ThreadLaunch.ThreadLaunchService)({ launch: (input) => { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7342c6d68c1fe..265bc47609432 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -155,11 +155,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", { @@ -281,8 +281,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 2a3e9e6f5865c..4930005f30eef 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -7,7 +7,9 @@ import { ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, + SecretProviderId, ServerSettings, + ServerSettingsPatch, UsageLimitSourceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -33,6 +35,7 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); +const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const layerServerSettings = () => ServerSettingsModule.layer.pipe( @@ -212,6 +215,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; @@ -267,6 +295,43 @@ 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("stores no secret provider of an unloaded kind or with a config its kind rejects", () => + Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + const write = (entry: unknown) => + decodeServerSettingsPatch({ secretProviders: { personal: entry } }).pipe( + Effect.flatMap(service.updateSettings), + Effect.flip, + ); + for (const entry of [ + { kind: "one_password", label: "Personal", config: { account: "--help" } }, + { kind: "one_password", label: "Personal" }, + { kind: "openbao", label: "Vault", config: { address: "https://x" } }, + ]) { + assert.deepInclude(yield* write(entry), { operation: "write-secret-provider" }); + } + assert.deepStrictEqual((yield* service.getSettings).secretProviders, {}); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("saves through a symlinked settings file without replacing the link", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1107,6 +1172,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", @@ -1378,6 +1491,27 @@ it.layer(NodeServices.layer)("server settings", (it) => { for (const name of secretNames) { assert.isTrue(Option.isSome(yield* store.get(name)), name); } + const secret = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Antigravity/token", + }; + const referenced = yield* service.updateProviderInstance({ + operation: "upsert", + instanceId, + instance: { + ...client.providerInstances[instanceId]!, + environment: [ + { name: "API_TOKEN", value: secret, sensitive: true, valueRedacted: true }, + ], + }, + }); + assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!))); + assert.deepEqual( + ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[ + instanceId + ]?.environment, + [{ name: "API_TOKEN", value: secret, sensitive: true }], + ); yield* service.updateSettings({ providerInstances: {}, sourceControlHosts: { @@ -1638,6 +1772,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", @@ -1648,7 +1783,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"); @@ -1670,6 +1805,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 db9ec0373af51..832b5e2be8745 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -3,6 +3,7 @@ import { ModelSelection, ProjectId, ProjectScript, + keepUnknownOverrideKeys, ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceMutation, @@ -51,6 +52,7 @@ import { isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import { secretProviderDefinitions } from "./secrets/secretProviderDefinitions.ts"; import { readSecretFieldValues, sourceControlHostSecretName, @@ -156,11 +158,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 (secretProviderConfigGuards.has(entry.kind)) return [id, entry]; + const { config: _config, ...envelope } = entry; + return [id, envelope]; + }), + ) as ServerSettings["secretProviders"]; } export function applyProviderInstanceMutation( @@ -176,6 +192,40 @@ export function applyProviderInstanceMutation( return { ...settings, providerInstances }; } +const secretProviderConfigGuards = new Map( + secretProviderDefinitions.definitions.map((definition) => [ + definition.kind as string, + Schema.is(definition.configSchema), + ]), +); + +// Rejects a kind this server has not loaded rather than storing a config it cannot check. +function ensureSecretProviderWritesAllowed( + patch: ServerSettingsPatch, + settingsPath: string, +): Effect.Effect { + for (const [id, entry] of Object.entries(patch.secretProviders ?? {})) { + if (entry === null) continue; + const isConfig = secretProviderConfigGuards.get(entry.kind); + const problem = + isConfig === undefined + ? `Unsupported secret provider kind: ${entry.kind}.` + : isConfig(entry.config) + ? undefined + : `Invalid ${entry.kind} config for secret provider ${id}.`; + if (problem !== undefined) { + return Effect.fail( + new ServerSettingsError({ + settingsPath, + operation: "write-secret-provider", + cause: new Error(problem), + }), + ); + } + } + return Effect.void; +} + function ensureProviderInstanceMutationAllowed( settings: ServerSettings, mutation: ProviderInstanceMutation, @@ -305,10 +355,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({}))), }), ); @@ -358,10 +410,15 @@ function foldLegacyWorktreeBranchPrefix(settings: ServerSettings, raw: string): const projectOverrides = { ...next.projectSettingsOverrides }; let projectsChanged = false; for (const [projectId, entry] of Object.entries(legacy.value.projectSettingsOverrides ?? {})) { + if (!Object.hasOwn(entry, "worktreeBranchPrefix")) continue; const prefix = legacyPrefix(entry); - if (prefix === undefined) continue; const id = ProjectId.make(projectId); - projectOverrides[id] = { ...projectOverrides[id], branchNamePrefix: prefix }; + const overrides = { ...projectOverrides[id] }; + Reflect.deleteProperty(overrides, "worktreeBranchPrefix"); + projectOverrides[id] = { + ...overrides, + ...(prefix === undefined ? {} : { branchNamePrefix: prefix }), + }; projectsChanged = true; } return projectsChanged ? { ...next, projectSettingsOverrides: projectOverrides } : next; @@ -931,6 +988,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) => @@ -1109,8 +1191,9 @@ const make = Effect.gen(function* () { foldContextRepositoryOwners(updated, current), ); 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)) { @@ -1123,22 +1206,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* () { @@ -1243,17 +1322,17 @@ 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)), + ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( + Effect.as(applyServerSettingsPatch(current, patch)), + ), ), updateProviderInstance: (mutation, patch = {}) => updateAndPersistSettings((current) => Effect.gen(function* () { + yield* ensureSecretProviderWritesAllowed(patch, settingsPath); yield* ensureProviderInstanceMutationAllowed(current, mutation, settingsPath); const patched = applyServerSettingsPatch(current, patch); return applyProviderInstanceMutation(patched, mutation); diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index dff6b96f52a1b..3697c877edc22 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -99,16 +99,19 @@ export function mapSettingsSecrets( ...(instance.environment ? { environment: instance.environment.map((variable) => { + const { valueRedacted: _omit, ...rest } = variable; + // A secret reference is not itself secret; storage sees the value cleared. + const reference = typeof variable.value === "string" ? undefined : variable.value; const result = map({ secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`, kind: "environment", - value: variable.value, - redacted: variable.valueRedacted === true, + value: typeof variable.value === "string" ? variable.value : "", + redacted: reference === undefined && variable.valueRedacted === true, sensitive: variable.sensitive, providerInstanceId: instanceId, environmentVariable: variable.name, }); - const { valueRedacted: _omit, ...rest } = variable; + if (reference !== undefined) return { ...rest, value: reference }; return { ...rest, value: result.value, diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 249bc07b3ad3f..05b6a1a8d0e42 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -118,7 +118,12 @@ function makeRegistry(input: { kind: provider.kind, label: provider.kind, installHint: "unused", - probeAuth: Effect.die("unused"), + probeAuth: Effect.succeed({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), }, })), ).pipe( @@ -440,3 +445,28 @@ it.effect("asks again once Settings change", () => assert.strictEqual((yield* resolveUnknown(registry, "c")).provider.kind, "github"); }), ); + +it.effect("answers discovery without waiting on a hosting API that stalls", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + discovery: [ + claimingCli("gitlab"), + { + type: "api", + kind: kind("bitbucket"), + label: "Bitbucket", + installHint: "unused", + probeAuth: Effect.never, + }, + ], + }); + const discovering = yield* registry.discover.pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + const items = yield* Fiber.join(discovering); + const bitbucket = items.find((item) => item.kind === "bitbucket"); + assert.strictEqual(bitbucket?.status, "available"); + assert.strictEqual(bitbucket?.auth.status, "unknown"); + assert.strictEqual(items.find((item) => item.kind === "gitlab")?.status, "available"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 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 164bf72e8ab2f..9c4daa4afce63 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"; @@ -33,8 +35,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, @@ -333,8 +338,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( @@ -404,7 +412,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), @@ -428,29 +437,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; }, @@ -490,6 +509,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; }, @@ -1005,6 +1030,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); @@ -1894,6 +2235,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 73ea0ae820b81..0e1a71b8aa96b 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -991,7 +991,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, @@ -1065,7 +1068,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"); @@ -1076,10 +1087,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)); @@ -1107,7 +1118,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 && @@ -1214,7 +1225,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) @@ -1222,10 +1233,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) => @@ -1250,7 +1264,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 @@ -1266,7 +1280,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; }); @@ -1339,6 +1353,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, @@ -1352,6 +1367,7 @@ const make = Effect.gen(function* () { yield* projectStore.list({ includeDeleted: true }), entries, ); + return { waiting, kept }; }); const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* ( @@ -1362,8 +1378,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 { @@ -1391,14 +1409,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({ @@ -1413,9 +1438,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, @@ -1556,14 +1585,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 c735357d18e88..26b6311faf854 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -12,20 +12,25 @@ import { TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; 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 SynchronizedRef from "effect/SynchronizedRef"; +import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -763,6 +768,154 @@ it.layer( }), ); + // Answers process-table queries after `queryDuration`, honoring the caller's + // timeout the way ProcessRunner's `timedOutResult` mode does. + const slowProcessTable = ( + platform: "linux" | "win32", + queryDuration: Duration.Input, + queries: Array<{ start: number; end?: number }>, + started: ReadonlyArray>, + ): ProcessRunner.ProcessRunner["Service"] => ({ + run: (input) => + Effect.gen(function* () { + const query: { start: number; end?: number } = { start: yield* Clock.currentTimeMillis }; + queries.push(query); + const deferred = started[queries.length - 1]; + if (deferred) yield* Deferred.succeed(deferred, undefined); + const completed = yield* Effect.sleep(queryDuration).pipe( + Effect.timeoutOption(input.timeout ?? "1 hour"), + ); + query.end = yield* Clock.currentTimeMillis; + return { + stdout: Option.isNone(completed) + ? "" + : platform === "win32" + ? "100|9000|node.exe\n" + : " 100 9000 node\n", + stderr: "", + code: Option.isSome(completed) ? ChildProcessSpawner.ExitCode(0) : null, + timedOut: Option.isNone(completed), + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + + const createFallbackManager = ( + platform: "linux" | "win32", + runner: ProcessRunner.ProcessRunner["Service"], + ) => + createManager(5, { subprocessPollIntervalMs: 1_000, processKillGraceMs: 0 }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.provide(layerWithHostPlatform(platform)), + ); + + it.effect.each(["linux", "win32"] as const)( + "reports activity from a slow but successful %s process-table query", + (platform) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager, getEvents } = yield* createFallbackManager( + platform, + slowProcessTable(platform, "5 seconds", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + + expect( + (yield* getEvents).some( + (event) => + event.type === "activity" && event.hasRunningSubprocess && event.label === "node", + ), + ).toBe(true); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("idles at least as long as a slow process-table query took before the next", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager } = yield* createFallbackManager( + "linux", + slowProcessTable("linux", "5 seconds", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust(Duration.millis(19_999)); + + expect(queries).toHaveLength(1); + yield* TestClock.adjust("1 millis"); + const [slow, next] = queries; + expect(next?.start).toBe(slow!.end! + 20_000); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("ends a hung process-table query and paces the retry", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager } = yield* createFallbackManager( + "win32", + slowProcessTable("win32", "1 day", queries, [started]), + ); + + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + yield* Deferred.await(started); + yield* TestClock.adjust("2 minutes"); + yield* TestClock.adjust("2 minutes"); + + const [hung, next] = queries; + expect(hung!.end! - hung!.start).toBe(15_000); + expect(next!.start - hung!.end!).toBe(60_000); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect.each([ + ["linux", 1_000], + ["win32", 1_500], + ] as const)( + "bounds closeIdle's %s query so other operations on the thread still run", + ([platform, budgetMs]) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const queries: Array<{ start: number; end?: number }> = []; + const { manager, ptyAdapter } = yield* createFallbackManager( + platform, + slowProcessTable(platform, "1 day", queries, [started]), + ); + yield* manager.open(openInput()); + + const closing = yield* manager + .closeIdle({ threadId: "thread-1" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + const resizing = yield* manager + .resize({ threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 80, rows: 30 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(Duration.millis(budgetMs)); + + expect(closing.pollUnsafe()).toBeDefined(); + expect(resizing.pollUnsafe()).toBeDefined(); + expect(queries[0]!.end! - queries[0]!.start).toBe(budgetMs); + expect(ptyAdapter.processes[0]!.resizeCalls).toEqual([{ cols: 80, rows: 30 }]); + expect(ptyAdapter.processes[0]!.killed).toBe(false); + yield* manager.close({ threadId: "thread-1" }); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("keeps terminals that get input or output while closeIdle checks them", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); @@ -888,6 +1041,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 91821a322d4e9..b6e6ce496ec4c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -55,6 +55,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Equal from "effect/Equal"; @@ -64,6 +65,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -107,6 +109,12 @@ const MAX_HISTORY_CHUNK_LENGTH = 16 * 1024; const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; +// `ps` and WMI can take seconds on a loaded host and their tables are still +// valid then; the timeout only ends a query that is truly stuck. +const PROCESS_TABLE_QUERY_TIMEOUT = "15 seconds"; +// closeIdle queries while holding the thread lock, so it keeps a short budget +// rather than stalling other operations on that thread behind a slow table. +const CLOSE_IDLE_QUERY_TIMEOUT = { posix: "1 second", win32: "1500 millis" } as const; // Discover children started without terminal input or output. const QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS = 30; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; @@ -687,8 +695,17 @@ interface TerminalProcessTableSnapshot { readonly commandById: ReadonlyMap; } -function subprocessSnapshotPollDelayMs(pollIntervalMs: number, failureCount: number): number { - return Math.min(pollIntervalMs * 2 ** failureCount, MAX_SUBPROCESS_POLL_INTERVAL_MS); +// Idles at least four times as long as the last fallback query took, so a slow +// process table never keeps the poll loop busy most of the time. +function subprocessSnapshotPollDelayMs( + pollIntervalMs: number, + failureCount: number, + queryElapsedMs: number, +): number { + return Math.min( + Math.max(pollIntervalMs * 2 ** failureCount, queryElapsedMs * 4), + MAX_SUBPROCESS_POLL_INTERVAL_MS, + ); } function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { @@ -776,6 +793,7 @@ const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(functi const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* ( psCommand: string, + timeout: Duration.Input, ): Effect.fn.Return< TerminalProcessTableSnapshot, TerminalSubprocessCheckError, @@ -786,7 +804,7 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" .run({ command: psCommand, args: ["-eo", "pid=,ppid=,comm="], - timeout: "1 second", + timeout, maxOutputBytes: 524_288, outputMode: "truncate", timeoutBehavior: "timedOutResult", @@ -812,48 +830,46 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" return parsePosixProcessTable(result.stdout); }); -const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")( - function* (): Effect.fn.Return< - TerminalProcessTableSnapshot, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner - > { - const processRunner = yield* ProcessRunner.ProcessRunner; - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - const result = yield* processRunner - .run({ - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 262_144, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), - ), - ); - if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - return yield* new TerminalSubprocessCheckError({ - command: "powershell", - exitCode: result.code, - timedOut: result.timedOut, - stdoutTruncated: result.stdoutTruncated, - }); - } - const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { - const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) - ? [{ pid, ppid, name }] - : []; +const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")(function* ( + timeout: Duration.Input, +): Effect.fn.Return< + TerminalProcessTableSnapshot, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner +> { + const processRunner = yield* ProcessRunner.ProcessRunner; + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + const result = yield* processRunner + .run({ + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout, + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => new TerminalSubprocessCheckError({ cause, command: "powershell" }), + ), + ); + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + return yield* new TerminalSubprocessCheckError({ + command: "powershell", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, }); - return processTableSnapshotFromProcesses(processes); - }, -); + } + const processes = result.stdout.split(/\r?\n/g).flatMap((line) => { + const [pidRaw, ppidRaw, name = ""] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + return Number.isInteger(pid) && pid > 0 && Number.isInteger(ppid) ? [{ pid, ppid, name }] : []; + }); + return processTableSnapshotFromProcesses(processes); +}); interface TerminalHistoryChunk { data: string; @@ -1079,6 +1095,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; @@ -1127,7 +1147,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) { @@ -1189,10 +1209,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; @@ -1243,10 +1264,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; @@ -1636,54 +1658,74 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }, ); // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. - const fallbackProcessTableSnapshot = ( - platform === "win32" - ? windowsProcessTableSnapshot() - : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) - ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); - const fetchProcessTableSnapshot: Effect.Effect< - { - readonly snapshot: TerminalProcessTableSnapshot; - readonly snapshotSucceeded: boolean; - }, - TerminalSubprocessCheckError - > = options.processTable - ? options.processTable.pipe( - Effect.map((entries) => ({ - snapshot: processTableSnapshotFromProcesses(entries), - snapshotSucceeded: true, - })), - Effect.catch(() => - fallbackProcessTableSnapshot.pipe( - Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: false })), + // Timed on success and failure so the poll loop can pace the next query. + const posixPsCommand = platform === "win32" ? undefined : yield* resolvePosixPsCommand(); + const fallbackProcessTableSnapshot = (timeout: Duration.Input) => + (posixPsCommand === undefined + ? windowsProcessTableSnapshot(timeout) + : posixProcessTableSnapshot(posixPsCommand, timeout) + ).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.result, + Effect.timed, + Effect.map(([elapsed, snapshot]) => ({ + snapshot, + queryElapsedMs: Duration.toMillis(elapsed), + })), + ); + // `fallbackTimeout` only bounds the ps/WMI fallback; a native process table + // or custom inspector is unaffected. + const fetchProcessTableSnapshot = ( + fallbackTimeout: Duration.Input, + ): Effect.Effect<{ + readonly snapshot: Result.Result; + readonly snapshotSucceeded: boolean; + readonly queryElapsedMs: number; + }> => + options.processTable + ? options.processTable.pipe( + Effect.map((entries) => ({ + snapshot: Result.succeed(processTableSnapshotFromProcesses(entries)), + snapshotSucceeded: true, + queryElapsedMs: 0, + })), + Effect.catch(() => + fallbackProcessTableSnapshot(fallbackTimeout).pipe( + Effect.map((fallback) => ({ ...fallback, snapshotSucceeded: false })), + ), ), - ), - ) - : fallbackProcessTableSnapshot.pipe( - Effect.map((snapshot) => ({ snapshot, snapshotSucceeded: true })), - ); + ) + : fallbackProcessTableSnapshot(fallbackTimeout).pipe( + Effect.map((fallback) => ({ + ...fallback, + snapshotSucceeded: Result.isSuccess(fallback.snapshot), + })), + ); const customSubprocessInspector = options.subprocessInspector; - const acquireSubprocessInspector: Effect.Effect< - { - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - }, - TerminalSubprocessCheckError - > = + const acquireSubprocessInspector = ( + fallbackTimeout: Duration.Input, + ): Effect.Effect<{ + readonly inspector: Result.Result; + readonly snapshotSucceeded: boolean; + readonly queryElapsedMs: number; + }> => customSubprocessInspector !== undefined - ? Effect.succeed({ inspector: customSubprocessInspector, snapshotSucceeded: true }) + ? Effect.succeed({ + inspector: Result.succeed(customSubprocessInspector), + snapshotSucceeded: true, + queryElapsedMs: 0, + }) : Effect.map( - fetchProcessTableSnapshot, - ({ - snapshot, - snapshotSucceeded, - }): { - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - } => ({ - inspector: (terminalPid) => - Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), + fetchProcessTableSnapshot(fallbackTimeout), + ({ snapshot, snapshotSucceeded, queryElapsedMs }) => ({ + inspector: Result.map( + snapshot, + (table): TerminalSubprocessInspector => + (terminalPid) => + Effect.succeed(deriveSubprocessInspectResult(table, terminalPid, platform)), + ), snapshotSucceeded, + queryElapsedMs, }), ); const subprocessPollIntervalMs = @@ -2731,39 +2773,29 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func })); if (runningSessions.length === 0) { - return true; + return { snapshotSucceeded: true, queryElapsedMs: 0 }; } if ( runningSessions.every(({ session }) => isQuiet(session)) && skippedQuietTicks < QUIET_SUBPROCESS_SNAPSHOT_EVERY_TICKS - 1 ) { skippedQuietTicks += 1; - return true; + return { snapshotSucceeded: true, queryElapsedMs: 0 }; } skippedQuietTicks = 0; - const inspectorOption = yield* acquireSubprocessInspector.pipe( - Effect.asSome, - Effect.catch((reason) => - Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { - reason, - }).pipe( - Effect.as( - Option.none<{ - readonly inspector: TerminalSubprocessInspector; - readonly snapshotSucceeded: boolean; - }>(), - ), - ), - ), + const { inspector, snapshotSucceeded, queryElapsedMs } = yield* acquireSubprocessInspector( + PROCESS_TABLE_QUERY_TIMEOUT, ); - - if (Option.isNone(inspectorOption)) { + if (Result.isFailure(inspector)) { + yield* Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { + reason: inspector.failure, + }); quietSessions = new WeakMap(); - return false; + return { snapshotSucceeded: false, queryElapsedMs }; } - const { inspector: subprocessInspector, snapshotSucceeded } = inspectorOption.value; + const subprocessInspector = inspector.success; const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( candidate: (typeof runningSessions)[number], ) { @@ -2859,7 +2891,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func discard: true, }); - return snapshotSucceeded; + return { snapshotSucceeded, queryElapsedMs }; }); const hasRunningSessions = readManagerState.pipe( @@ -2874,13 +2906,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.flatMap((active) => active ? pollSubprocessActivity().pipe( - Effect.flatMap((snapshotSucceeded) => { + Effect.flatMap(({ snapshotSucceeded, queryElapsedMs }) => { subprocessSnapshotFailureCount = snapshotSucceeded ? 0 : Math.min(subprocessSnapshotFailureCount + 1, 30); const delayMs = subprocessSnapshotPollDelayMs( subprocessPollIntervalMs, subprocessSnapshotFailureCount, + queryElapsedMs, ); return Effect.sleep(delayMs); }), @@ -3516,7 +3549,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); // Inspect now so commands started after the last poll still protect their terminals. - const { inspector } = yield* acquireSubprocessInspector; + const { inspector: inspectorResult } = yield* acquireSubprocessInspector( + platform === "win32" ? CLOSE_IDLE_QUERY_TIMEOUT.win32 : CLOSE_IDLE_QUERY_TIMEOUT.posix, + ); + const inspector = yield* Effect.fromResult(inspectorResult); yield* Effect.forEach( running, (session) => 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/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 80826a66ec556..7ae8c6a07ed17 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -148,6 +148,7 @@ const layerService = (input: { readonly platform?: NodeJS.Platform; /** Answers Cursor's key exchange and dashboard; the default refuses every request. */ readonly onCursorRequest?: (request: HttpClientRequest.HttpClientRequest) => Response | undefined; + readonly hostname?: string; }) => layerCursorUsageAccounts( CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), @@ -164,6 +165,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( @@ -480,6 +482,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", () => @@ -2266,7 +2355,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v9.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v10.json"); assert.strictEqual( yield* Effect.promise(() => NodeFSP.readFile(legacyPath, "utf8")), legacy, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 738dceae53d10..3718648dfa643 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, @@ -132,7 +130,7 @@ function findDevinProviderEnvironmentValue( for (const instance of Object.values(settings.providerInstances)) { if (String(instance.driver).toLowerCase() !== "devin") continue; for (const variable of instance.environment ?? []) { - if (!wanted.has(variable.name.toUpperCase())) continue; + if (!wanted.has(variable.name.toUpperCase()) || typeof variable.value !== "string") continue; const resolved = nonEmptyString(variable.value); if (resolved !== undefined) return resolved; } @@ -262,6 +260,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(); @@ -284,6 +283,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 liteLlmRates: RateTable = new Map(); let devinRates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; @@ -918,7 +948,6 @@ export const make = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureScanCacheLoaded; - const hostId = NodeOS.hostname(); yield* ensureDevinRates(); const accountUsage = yield* readDevinAccountUsage(input, settings); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 552c97585b87e..477b8bb6fc4f6 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -218,7 +218,11 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual(original.get("/gone.jsonl")); + expect(restored.get("/gone.jsonl")).toEqual({ + ...original.get("/gone.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-codex.jsonl")).toEqual({ ...original.get("/gone-codex.jsonl"), size: -1, @@ -245,6 +249,18 @@ describe("scan cache round trip", () => { }, ); + it("re-parses a Claude transcript cached before advisor usage was counted", () => { + const original = cacheWith([["/claude.jsonl", 100, [record()]]]); + const v9 = { ...JSON.parse(makeScanCacheWriter()(original, {})), version: 9 }; + + const restored = decodeScanCache(JSON.parse(JSON.stringify(v9)), TEST_FORMAT_MAP); + expect(restored.get("/claude.jsonl")).toEqual({ + ...original.get("/claude.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); + }); + it("rewrites only changed entries and still restores the whole cache", () => { const write = makeScanCacheWriter(); const cache = cacheWith([ diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index a74bc1840c025..70c0c3df17e3e 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 9 as const; +const USAGE_SCAN_CACHE_VERSION = 10 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +17,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * both. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v9.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v10.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", "usage-scan-cache-v7.json", "usage-scan-cache-v6.json", @@ -291,10 +292,12 @@ export function decodeScanCache( } // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 repairs skipped initial Codex requests; clean v8 entries can still resume. + // v10 adds Claude advisor calls, which no older Claude entry holds. const legacy = - format.state !== undefined && - (version < 6 || - (entry.p === "codex" && (version < 8 || (version < 9 && malformedRecords > 0)))); + (entry.p === "claude" && version < 10) || + (format.state !== undefined && + (version < 6 || + (entry.p === "codex" && (version < 8 || (version < 9 && malformedRecords > 0))))); // A corrupt state disqualifies the entry: resuming with it would attach // appended usage to the wrong model or replay fork-copied history. if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue; 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 e3249c70882ec..74e409a8bc184 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -20,6 +20,8 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -328,6 +330,9 @@ export class GitVcsDriver extends Context.Service< cwd: string, baseRef: string, ) => Effect.Effect; + readonly getReviewBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; @@ -587,25 +592,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 +922,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 +932,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 16d2aa5df4ee2..df75ac73a20bd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; @@ -404,6 +406,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 @@ -919,6 +922,54 @@ it.effect("retries a fetch that lost a ref update to another Git process", () => }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), ); +it.effect("lets a worktree checkout that is still working run past five minutes", () => + Effect.gen(function* () { + const checkoutStarted = yield* Deferred.make(); + const checkoutFinished = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("expected Git command"); + if (!command.args.includes("worktree")) return makeSuccessfulHandle(""); + yield* Deferred.succeed(checkoutStarted, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + exitCode: Deferred.await(checkoutFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + stderr: Stream.encodeText( + Stream.concat( + Stream.make("Updating files: 40% (49052/122629)\n"), + Stream.fromEffect( + Deferred.await(checkoutFinished).pipe( + Effect.as("Updating files: 100% (122629/122629), done.\n"), + ), + ), + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const worktreePath = path.join(cwd, "worktree"); + + const creation = yield* driver + .createWorktree({ cwd, refName: "main", newRefName: "t3/slow-checkout", path: worktreePath }) + .pipe(Effect.forkChild); + yield* Deferred.await(checkoutStarted); + yield* TestClock.adjust("6 minutes"); + yield* Deferred.succeed(checkoutFinished, undefined); + + const result = yield* Fiber.join(creation); + assert.equal(result.worktree.path, worktreePath); + assert.equal(result.worktree.refName, "t3/slow-checkout"); + }).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))), +); + describe("rejected git refs", () => { const remoteUrl = "https://user:token-123@git.example.invalid/org/secret-repo.git"; const failedPush = `error: failed to push some refs to '${remoteUrl}'`; @@ -1002,6 +1053,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* () { @@ -1378,6 +1468,140 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("diffs a root commit against the empty tree but rejects a shallow boundary", () => + Effect.gen(function* () { + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(upstream); + const driver = yield* GitVcsDriver.GitVcsDriver; + const emptyTree = yield* git(upstream, ["hash-object", "-t", "tree", NodeOS.devNull]); + const root = yield* git(upstream, ["rev-parse", "HEAD"]); + const rootSource = (yield* driver.getReviewDiffPreview({ cwd: upstream, commitSha: root })) + .sources[0]!; + assert.equal(rootSource.baseRef, emptyTree); + assert.include(rootSource.diff, "+# test"); + + yield* writeTextFile(upstream, "README.md", "# boundary\n"); + yield* git(upstream, ["commit", "-am", "boundary"]); + const sha = yield* git(upstream, ["rev-parse", "HEAD"]); + const cwd = yield* makeTmpDir(); + yield* git(upstream, ["clone", "--depth=1", NodeURL.pathToFileURL(upstream).href, cwd], { + GIT_CONFIG_GLOBAL: NodeOS.devNull, + GIT_CONFIG_NOSYSTEM: "1", + }); + assert.equal(yield* git(cwd, ["rev-parse", "--is-shallow-repository"]), "true"); + for (const request of [ + driver.getReviewDiffPreview({ cwd, commitSha: sha }), + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + changeType: "new", + headRef: sha, + baseRef: emptyTree, + }), + ), + ]) { + const error = yield* Effect.asVoid(request).pipe( + Effect.flip, + ); + assert.include(error.detail, "parent"); + } + }), + ); + + it.effect("lists the branch's commits newest first, at most 100, and flags the rest", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/commits"]); + for (let index = 0; index < 102; index++) { + yield* git(cwd, ["commit", "--allow-empty", "-m", `feature ${index}`]); + } + + const result = yield* driver.getReviewBranchCommits({ cwd, baseRef: initialBranch }); + assert.strictEqual(result.truncated, true); + assert.strictEqual(result.commits.length, 100); + assert.strictEqual(result.commits[0]?.sha, yield* git(cwd, ["rev-parse", "HEAD"])); + assert.deepStrictEqual( + [result.commits[0]?.subject, result.commits[99]?.subject], + ["feature 101", "feature 2"], + ); + }), + ); + + it.effect("previews one selected commit, a merge against its first parent", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/merge-preview"]); + yield* git(cwd, ["checkout", "-b", "feature/side"]); + yield* writeTextFile(cwd, "README.md", "# side commit\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "side change"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "feature.txt", "feature commit\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature change"]); + const firstParent = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["merge", "--no-ff", "--no-edit", "feature/side"]); + const mergeSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/elsewhere", initialBranch]); + yield* writeTextFile(cwd, "elsewhere.txt", "elsewhere\n"); + yield* git(cwd, ["add", "elsewhere.txt"]); + yield* git(cwd, ["commit", "-m", "elsewhere change"]); + const elsewhereSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "README.md", "# dirty tree\n"); + + const elsewhere = yield* driver.getReviewDiffPreview({ cwd, commitSha: elsewhereSha }); + assert.deepStrictEqual( + elsewhere.sources + .find((candidate) => candidate.kind === "commit") + ?.files?.map((file) => file.path), + ["elsewhere.txt"], + ); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + }); + const source = preview.sources.find((candidate) => candidate.kind === "commit")!; + assert.strictEqual(source.baseRef, firstParent); + assert.strictEqual(source.headRef, mergeSha); + assert.deepStrictEqual( + source.files?.map((file) => file.path), + ["README.md"], + ); + assert.include(source.diff, "+# side commit"); + assert.notInclude(source.diff, "dirty tree"); + assert.notInclude(source.diff, "feature.txt"); + + const patch = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + file: { path: "README.md", previousPath: null, sourceKind: "commit" }, + }); + assert.include( + patch.sources.find((candidate) => candidate.kind === "commit")!.diff, + "+# side commit", + ); + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + baseRef: source.baseRef, + headRef: source.headRef, + }), + ); + assert.deepStrictEqual(contents, { + oldContents: "# test\n", + newContents: "# side commit\n", + }); + }), + ); + it.effect("loads repository-relative files from a nested project directory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2283,6 +2507,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(); @@ -3475,6 +3715,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..c5bdf3d068ef9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -26,6 +26,7 @@ import { GitCommandError, type GitCommandFailureReason, T3_PROJECT_FILE_NAME, + type ReviewBranchCommitsInput, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -62,7 +63,6 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` */ const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); -const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -75,6 +75,9 @@ const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +const REVIEW_BRANCH_COMMIT_LIMIT = 100; +const REVIEW_BRANCH_SUBJECT_LIMIT = 300; +const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; // Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const REVIEW_DIFF_ARGS = [ @@ -514,6 +517,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 +1424,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 +1455,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 +1947,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 +1969,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 +1981,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 +2094,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 +2112,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 +2161,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); }), + Effect.result, ), statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) @@ -2181,6 +2172,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 +2237,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 +2291,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 +2455,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 +2519,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 +2798,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 +3023,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 +3035,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 +3069,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({ @@ -3094,6 +3119,141 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { baseRef, insertions, deletions }; }, Effect.scoped); + const reviewCommitError = (cwd: string, detail: string) => + new GitCommandError({ + operation: "GitVcsDriver.reviewCommit", + command: "git cat-file", + cwd, + detail, + }); + + const resolveReviewCommit = Effect.fn("resolveReviewCommit")(function* ( + cwd: string, + sha: string, + ) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + const present = yield* executeGit( + "GitVcsDriver.reviewCommit.present", + cwd, + ["cat-file", "-e", `${sha}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (present.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + // The raw object keeps the parents a shallow clone grafts away, so a boundary is not a root. + const object = yield* runGitStdout("GitVcsDriver.reviewCommit.parents", cwd, [ + "cat-file", + "commit", + sha, + ]); + const secondLine = object.split("\n", 2)[1]; + const firstParent = secondLine?.startsWith("parent ") ? secondLine.slice(7) : undefined; + if (firstParent !== undefined) { + const parent = yield* executeGit( + "GitVcsDriver.reviewCommit.parent", + cwd, + ["cat-file", "-e", `${firstParent}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (parent.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit's parent is not available locally."); + } + } + return { sha: sha.toLowerCase(), parent: firstParent ?? (yield* readEmptyTreeHash(cwd)) }; + }); + + const readReviewCommitMessage = Effect.fn("GitVcsDriver.readReviewCommitMessage")(function* ( + cwd: string, + sha: string, + ) { + const result = yield* executeGit( + "GitVcsDriver.readReviewCommitMessage", + cwd, + ["show", "-s", "--no-notes", "--no-show-signature", "--format=%B", sha], + { maxOutputBytes: 8_192, appendTruncationMarker: true }, + ); + const endOfSubject = result.stdout.indexOf("\n"); + return { + subject: result.stdout.slice(0, endOfSubject < 0 ? undefined : endOfSubject).slice(0, 300), + }; + }); + + const getReviewBranchCommits = Effect.fn("getReviewBranchCommits")(function* ( + input: ReviewBranchCommitsInput, + ) { + const repository = yield* resolveRepositoryPathsUncached(input.cwd); + if (!repository?.worktreeRoot) return { commits: [], truncated: false }; + const cwd = repository.worktreeRoot; + const head = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (head.exitCode !== 0) { + if (head.exitCode === 1 && head.stderr.trim() === "") { + return { commits: [], truncated: false }; + } + return yield* reviewCommitError(cwd, "Could not resolve the current branch commit."); + } + const { mergeBase } = yield* resolveReviewMergeBase( + cwd, + repository.currentBranch, + input.baseRef, + ); + const result = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits", + cwd, + [ + "log", + "-z", + "--no-notes", + "--no-show-signature", + "--format=%H%x00%s%x00%cI", + `--max-count=${REVIEW_BRANCH_COMMIT_LIMIT + 1}`, + `${mergeBase}..HEAD`, + ], + { + maxOutputBytes: REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + if (!result.stdoutTruncated && result.stdout.length > 0 && !result.stdout.endsWith("\0")) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + const fields = result.stdout.split("\0"); + const completeFieldCount = fields.length - 1; + const commits: Array<{ sha: string; subject: string; committedAt: string }> = []; + for ( + let index = 0; + index + 2 < completeFieldCount && commits.length <= REVIEW_BRANCH_COMMIT_LIMIT; + index += 3 + ) { + const sha = fields[index]; + const subject = fields[index + 1]; + const committedAt = fields[index + 2]; + if ( + !sha || + subject === undefined || + !committedAt || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) + ) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + commits.push({ sha, subject: subject.slice(0, REVIEW_BRANCH_SUBJECT_LIMIT), committedAt }); + } + if (result.stdoutTruncated && commits.length === 0) { + return yield* reviewCommitError(cwd, "Branch commit metadata exceeds the response limit."); + } + return { + commits: commits.slice(0, REVIEW_BRANCH_COMMIT_LIMIT), + truncated: result.stdoutTruncated || commits.length > REVIEW_BRANCH_COMMIT_LIMIT, + }; + }); + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -3120,6 +3280,64 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const cwd = repository.worktreeRoot; + const hashDiff = (diff: string, files: ReadonlyArray) => + crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + Effect.map(Hex.encode), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", + cwd, + detail: "Failed to hash review diff.", + cause, + }), + ), + ); + if (input.commitSha !== undefined || input.file?.sourceKind === "commit") { + if (!input.commitSha || (input.file && input.file.sourceKind !== "commit")) { + return yield* reviewCommitError(cwd, "Selected commit request is invalid."); + } + const commit = yield* resolveReviewCommit(cwd, input.commitSha); + const { subject } = yield* readReviewCommitMessage(cwd, commit.sha); + const diffArgs = [ + ...REVIEW_DIFF_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const stat = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitStat", + cwd, + [...diffArgs, "--numstat", "-z", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + const files = parseReviewNumstat(stat.stdout); + const patch = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitPatch", + cwd, + [...diffArgs, "--patch", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true }, + ); + const diffHash = yield* hashDiff(patch.stdout, files); + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [ + { + id: commit.sha, + kind: "commit" as const, + title: `Commit ${commit.sha.slice(0, 7)}`, + ...(subject.trim() ? { commitSubject: subject.trim() } : {}), + baseRef: commit.parent, + headRef: commit.sha, + diff: patch.stdout, + files, + diffHash, + truncated: patch.stdoutTruncated, + }, + ], + }; + } + const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD"; const review = input.file?.sourceKind === "working-tree" @@ -3199,20 +3417,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const baseFiles = baseResult.files; const dirtyDiff = dirtyTrackedResult.stdout; const baseDiff = baseResult.stdout; - const hashDiff = (diff: string, files: ReadonlyArray) => - crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( - Effect.map(Hex.encode), - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview.hash", - command: "crypto.digest SHA-256", - cwd, - detail: "Failed to hash review diff.", - cause, - }), - ), - ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff, dirtyFiles ?? []), hashDiff(baseDiff, baseFiles ?? []), @@ -3364,6 +3568,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryRoot.length === 0) { return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); } + if (input.sourceKind === "commit") { + if (!input.headRef) { + return yield* reviewDiffFileError(input, "Selected commit is no longer available."); + } + const commit = yield* resolveReviewCommit(input.cwd, input.headRef); + if (input.baseRef !== commit.parent) { + return yield* reviewDiffFileError( + input, + "Selected commit changed. Refresh the comparison.", + ); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.parent, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.sha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } const oldRevision = input.sourceKind === "working-tree" ? (input.baseRef ?? "HEAD") @@ -3806,7 +4034,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 +4052,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. @@ -3846,7 +4083,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["-c", `checkout.workers=${checkoutWorkers}`, ...args], { fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + // Large checkouts and post-checkout hooks can legitimately run for many + // minutes; a fixed deadline kills them and leaves an orphaned worktree. + timeoutMs: null, ...(onCheckoutProgress ? { // Git only prints checkout progress when stderr is a tty or the @@ -4617,6 +4856,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(cwd, pullCurrentBranch(cwd)); }), readRangeContext, + getReviewBranchCommits, getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, 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 be627234d3d67..f1f90cc17d05c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -441,12 +441,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: [], }), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 77fe256e1609f..582a1220e4e38 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,7 +124,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, @@ -134,7 +133,7 @@ import { shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, - shellSnapshotForSubscriber, + loadSubscriberShellSnapshot, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -1022,21 +1021,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, @@ -1813,6 +1814,8 @@ export const layerWsRpc = ( threadSnapshotPagination: true, threadFind: true, threadFindProgressive: true, + secretProviderSettings: true, + secretReferences: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -1939,6 +1942,8 @@ export const layerWsRpc = ( ), ), [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), + [ORCHESTRATION_V2_WS_METHODS.forceStopThread]: (input) => + threadManagement.forceStopThread(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => @@ -2344,9 +2349,11 @@ export const layerWsRpc = ( cwd: input.cwd, fresh: input.fresh === true, }) - : input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh(); + : input.reloadSecrets === true + ? providerRegistry.reloadSecretsAndRefresh(input.instanceId) + : input.instanceId !== undefined + ? providerRegistry.refreshInstance(input.instanceId) + : providerRegistry.refresh(); if (input.refreshModels) { const instances = yield* providerInstances.listInstances; for (const instance of instances) { @@ -3079,6 +3086,7 @@ export const layerWsRpc = ( gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), diff --git a/apps/web/package.json b/apps/web/package.json index b7420992fdd2c..c1b5516879568 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "scripts": { @@ -34,6 +34,8 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/secret-provider-definitions": "workspace:*", + "@t3tools/secret-provider-onepassword": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-definitions": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index 5fe50225304ea..8fab8cc1b03ba 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -1,7 +1,12 @@ -import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthPreviewOperateScope, + type BrowserLinkTarget, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; import { isQuickMessageWindow } from "~/components/quickMessage/quickMessageWindow"; +import { readEnvironmentScope } from "~/state/session"; import { isPreviewAvailableFor } from "./previewRuntime"; export interface ResolveLinkTargetInput { @@ -41,5 +46,9 @@ export async function resolveBrowserLinkTargetPreference(): Promise - + + {isElectron ? : null} ); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 98834003dd216..d196198fa0dd5 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -208,6 +208,7 @@ import { import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks"; import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; import { MarkdownTable } from "./chat/MarkdownTable"; import { MarkdownDetails } from "./chat/MarkdownDetails"; @@ -500,6 +501,44 @@ const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId }).pipe(Atom.withLabel("markdown-server-config-fields")), ); +function fileLinkSourcesKey( + text: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +): string { + const hrefs = extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text)) + .filter((href) => !parseComposerContextHref(href)) + .map(normalizeMarkdownLinkHrefKey) + .filter((href) => resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd)); + const spans = extractInlineCodeSpans(text).filter((span) => + resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd), + ); + return JSON.stringify([[...new Set(hrefs)], [...new Set(spans)]]); +} + +function resolveFileLinkSources( + key: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +) { + const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; + const markdownFileLinkMetaByHref = new Map(); + for (const href of hrefs) { + const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + if (meta) markdownFileLinkMetaByHref.set(href, meta); + } + const inlineCodeFileLinkMetaByText = new Map(); + for (const span of spans) { + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + if (meta) inlineCodeFileLinkMetaByText.set(span, meta); + } + const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]); + return { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath }; +} + function useChatMarkdownState({ text, inline = false, @@ -680,40 +719,6 @@ function useChatMarkdownState({ }); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = (() => { - const metaByHref = new Map< - string, - NonNullable> - >(); - for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { - if (parseComposerContextHref(href)) continue; - const normalizedHref = normalizeMarkdownLinkHrefKey(href); - if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByHref.set(normalizedHref, meta); - } - } - return metaByHref; - })(); - const inlineCodeFileLinkMetaByText = (() => { - const metaByText = new Map(); - for (const span of extractInlineCodeSpans(text)) { - if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByText.set(span, meta); - } - } - return metaByText; - })(); - const fileLinkParentSuffixByPath = (() => { - const filePaths = [ - ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), - ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), - ]; - return buildFileLinkParentSuffixByPath(filePaths); - })(); const markdownUrlTransform = (href: string) => { if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; @@ -741,6 +746,12 @@ function useChatMarkdownState({ // Subscribe so hydrated settings apply before the anchor synchronously decides whether to intercept _blank. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const mathEnabled = useClientSettings((settings) => settings.mathRenderingEnabled); + // Keyed by the file links found, not the text, so streamed text that adds none keeps the + // renderer context, and every block reading it, unchanged. + // A template literal marks the key as a string, so the compiler memoizes the maps on its value. + const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; + const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = + resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -1946,6 +1957,32 @@ const CHAT_MARKDOWN_COMPONENTS = { }, } satisfies Components; +const MarkdownSource = memo(function MarkdownSource({ + text, + remarkPlugins, + rehypePlugins, + urlTransform, +}: { + text: string; + remarkPlugins: NonNullable; + rehypePlugins: NonNullable; + urlTransform: ReactMarkdownOptions["urlTransform"]; +}) { + return ( + + + {text} + + + ); +}); + function ChatMarkdown({ text, className, @@ -1964,8 +2001,17 @@ function ChatMarkdown({ localMediaPreview, closeLocalMediaPreview, } = useChatMarkdownState({ text, inline, ...props }); + // Streaming re-renders only the last block; offsets for task edits need the whole text. + const streamingBlocks = + props.isStreaming === true && + !inline && + extraRemarkPlugins.length === 0 && + !props.onTaskListChange + ? splitMarkdownBlocks(text) + : null; const incrementalParsing = props.isStreaming === true && + streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const Root = inline ? "span" : "div"; @@ -2021,8 +2067,11 @@ function ChatMarkdown({ onCopy={handleCopy} > - - ( + - {text} - - + /> + ))} {localMediaPreview ? ( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 03e319268d392..89061036d92dc 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 fef4c13b72d46..a9a9795c9b58f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -331,7 +331,7 @@ 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"; @@ -1074,7 +1074,12 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { const explicitThreadRef = explicitDiffOpenRef.current; explicitDiffOpenRef.current = null; - if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { + if ( + diffOpen && + activeThreadRef && + explicitThreadRef !== activeThreadRef && + !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) + ) { useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } }, [activeThreadRef, diffOpen]); @@ -1295,9 +1300,6 @@ export default function ChatView(props: ChatViewProps) { const canChangeEnvironment = hasMultipleEnvironments && !envLocked && draftId !== null && !draftThread?.promotedTo; - const threadDetailsUsageEnabled = useClientSettings( - (settings) => settings.threadDetailsUsageEnabled, - ); const loadBalancingSettings = useClientSettings((settings) => ({ loadBalancingEnabled: settings.loadBalancingEnabled, loadBalancingWeights: settings.loadBalancingWeights, @@ -2520,7 +2522,8 @@ export default function ChatView(props: ChatViewProps) { const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, - backgroundWorkNow, + attentionNow, + workNow, childInputNow, goalNow, handleSwitchCheckoutToThread, @@ -2896,6 +2899,11 @@ export default function ChatView(props: ChatViewProps) { : EMPTY_PROVIDER_SKILLS; const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); + const openThreadFindStable = useStableCallback((threadRef: ScopedThreadRef) => { + if (threadFindEnabled && scopedThreadKey(threadRef) === activeThreadKey) { + openThreadFind(activeThreadKey); + } + }); useChatShortcuts({ enabled: !unfocusedSplitPane && activeThreadId !== null && !backgroundThread, @@ -3277,10 +3285,7 @@ export default function ChatView(props: ChatViewProps) { const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase; const hasThreadNow = - childInputNow !== null || - backgroundWorkNow !== null || - goalNow !== null || - shownThreadSyncPhase !== null; + attentionNow !== null || workNow !== null || goalNow !== null || shownThreadSyncPhase !== null; const queuedRunCount = serverProjection ? getUserQueuedThreadRuns(serverProjection).length : 0; // The queue control is mounted for every server thread but joins the footer only while it shows // something, by its own rule: an optimistic message stops counting once the server has it. @@ -3319,8 +3324,8 @@ export default function ChatView(props: ChatViewProps) { )} @@ -3538,6 +3543,7 @@ export default function ChatView(props: ChatViewProps) { {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} + {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> @@ -3848,7 +3854,7 @@ export default function ChatView(props: ChatViewProps) { : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup} - bannerItems={composerBannerItems} + bannerItems={isDraftHeroState ? [] : composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} onToggleKeepFullHistory={toggleKeepFullHistoryStable} @@ -4028,6 +4034,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}
(null); const [codeViewRevision, setCodeViewRevision] = useState(0); const routeThreadRef = useThreadRouteRef(); @@ -263,8 +269,14 @@ export default function DiffPanel({ const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const rememberedBaseRef = useDiffPanelStore((state) => + selectThreadBranchBaseRef(state, routeThreadRef), + ); const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; - const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged"; + const isGitSelection = + diffSelection.kind === "branch" || + diffSelection.kind === "unstaged" || + diffSelection.kind === "commit"; const toolCallItems = useQuietThreadVisibleTurnItems(toolCallSelection ? routeThreadRef : null); const toolCallDetail = useTurnItemDetail( toolCallSelection && routeThreadRef @@ -294,8 +306,15 @@ export default function DiffPanel({ }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; - const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedGitScope = + diffSelection.kind === "unstaged" || diffSelection.kind === "commit" + ? diffSelection.kind + : "branch"; + const selectedCommitSha = diffSelection.kind === "commit" ? diffSelection.sha : null; + const selectedBaseRef = + diffSelection.kind === "branch" || diffSelection.kind === "commit" + ? diffSelection.baseRef + : null; const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; const selectedFileRevealRequestId = diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; @@ -306,31 +325,17 @@ export default function DiffPanel({ orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; const latestTurn = orderedTurnDiffSummaries[0]; - const selectedScopeLabel = toolCallSelection - ? "Tool call" - : selectedRunId === null - ? selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes" - : selectedTurn?.runId === latestTurn?.runId - ? "Latest turn" - : `Turn ${selectedCheckpointTurnCount ?? "?"}`; const reviewSectionId = toolCallSelection ? `tool-call:${toolCallSelection.threadId}:${toolCallSelection.itemId}` : selectedTurn ? `turn:${selectedTurn.runId}` - : selectedGitScope; + : selectedCommitSha !== null + ? `commit:${selectedCommitSha}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const reviewSectionTitle = toolCallSelection - ? "Tool call" - : selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { @@ -354,6 +359,7 @@ export default function DiffPanel({ ? { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ...(selectedCommitSha !== null ? { commitSha: selectedCommitSha } : {}), ignoreWhitespace: diffIgnoreWhitespace, } : undefined; @@ -372,13 +378,69 @@ export default function DiffPanel({ ) : null, ); + const branchCommitsQuery = useEnvironmentQuery( + visible && isGitRepo && canReadFiles && activeThread && activeCwd + ? reviewEnvironment.branchCommits({ + environmentId: activeThread.environmentId, + input: { + cwd: activeCwd, + ...(rememberedBaseRef ? { baseRef: rememberedBaseRef } : {}), + }, + }) + : null, + ); + const branchCommits = openCommitMenu ?? branchCommitsQuery.data; const activeThreadRefreshKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` : null; const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => + source.kind === + (selectedGitScope === "unstaged" + ? "working-tree" + : selectedGitScope === "commit" + ? "commit" + : "branch-range"), ); + const selectedCommitSubject = + selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha + ? selectedGitSource.commitSubject + : undefined; + const selectedGitScopeLabel = + selectedCommitSha !== null + ? (selectedCommitSubject ?? `Commit ${selectedCommitSha.slice(0, 7)}`) + : selectedGitScope === "unstaged" + ? "Uncommitted" + : "Changes"; + const selectedScopeLabel = toolCallSelection + ? "Tool call" + : selectedRunId === null + ? selectedCommitSha !== null + ? (selectedCommitSubject ?? "Commit") + : selectedGitScopeLabel + : selectedTurn?.runId === latestTurn?.runId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionTitle = toolCallSelection + ? "Tool call" + : selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : selectedGitScopeLabel; + const headerCommitSha = selectedRunId === null ? selectedCommitSha : null; + const selectedCommitUrl = + headerCommitSha !== null + ? projectCommitUrl(activeProject?.repositoryIdentity, headerCommitSha) + : null; + const openLink = useOpenLink(routeThreadRef); + // A SHA link goes to the code host; it must not route back into this pane's commit selection. + const openCommitUrl = (event: MouseEvent, url: string) => { + event.stopPropagation(); + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button === 1) + return; + event.preventDefault(); + void openLink(url, { event }); + }; const refreshPreviewQuery = branchDiffPreview.refresh; const refreshDiffFromUserAction = refreshPreviewQuery; @@ -512,7 +574,8 @@ export default function DiffPanel({ environmentId: activeThread?.environmentId, cwd: branchDiffPreview.data?.cwd, source: lazySource, - baseRef: lazySource?.baseRef ?? selectedBaseRef, + baseRef: + selectedCommitSha !== null ? selectedBaseRef : (lazySource?.baseRef ?? selectedBaseRef), ignoreWhitespace: diffIgnoreWhitespace, theme: resolvedTheme, previewInput: branchDiffPreviewInput, @@ -693,16 +756,23 @@ export default function DiffPanel({ useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : ""; + const selectedCommitValue = selectedCommitSha !== null ? `commit:${selectedCommitSha}` : ""; const selectedScopeValue = toolCallSelection ? "" : selectedRunId === null - ? selectedGitScope + ? selectedCommitValue || selectedGitScope : selectedTurn?.runId === latestTurn?.runId ? "latest" : selectedTurnValue; const selectScopeValue = (value: string) => { if (value === "unstaged" || value === "branch") { selectGitScope(value); + } else if (value === selectedCommitValue) { + selectGitScope("branch"); + } else if (value.startsWith("commit:")) { + if (routeThreadRef) { + useDiffPanelStore.getState().selectCommit(routeThreadRef, value.slice("commit:".length)); + } } else if (value === "latest") { if (latestTurn) selectTurn(latestTurn.runId); } else { @@ -717,13 +787,37 @@ export default function DiffPanel({ } - className="max-w-full" + className={cn("max-w-full", headerCommitSha !== null && "min-w-0 shrink")} aria-label={`Diff scope: ${selectedScopeLabel}`} data-workspace-focus-default > {selectedScopeLabel} + {headerCommitSha !== null ? ( + selectedCommitUrl ? ( + + openCommitUrl(event, selectedCommitUrl)} + /> + } + > + {headerCommitSha.slice(0, 7)} + + {selectedCommitUrl} + + ) : ( + + {headerCommitSha.slice(0, 7)} + + ) + ) : null} @@ -760,6 +854,99 @@ export default function DiffPanel({ + { + setOpenCommitMenu(open ? (branchCommitsQuery.data ?? null) : null); + if (!open) return; + // The menu keeps the list it opened with; the refreshed one shows next time. + branchCommitsQuery.refresh(); + }} + > + Commits + + + {branchCommits?.commits.map((commit) => { + const commitUrl = projectCommitUrl( + activeProject?.repositoryIdentity, + commit.sha, + ); + return ( +
+ + + } + > + + {commit.subject} + + {formatRelativeTimeLabel(commit.committedAt)} + + + + + {commit.subject} + + + {commitUrl ? ( + + } + onClick={(event) => openCommitUrl(event, commitUrl)} + > + + {commit.sha.slice(0, 7)} + + + ) : ( + + {commit.sha.slice(0, 7)} + + )} +
+ ); + })} +
+ {branchCommitsQuery.error ? ( + {branchCommitsQuery.error} + ) : !canReadFiles ? ( + + {fileAccess.isPending + ? "Checking file access..." + : (fileAccess.error ?? "This connection cannot read local diffs.")} + + ) : branchCommits === null ? ( + Loading commits... + ) : branchCommits.commits.length === 0 ? ( + No commits on this branch. + ) : branchCommits.truncated ? ( + + {`Showing latest ${branchCommits.commits.length} commits`} + + ) : null} +
+
{isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( @@ -894,7 +1081,12 @@ export default function DiffPanel({
)} -
+
{codeViewFiles.length > 0 || (isGitSelection && selectedGitSource?.files?.length) ? ( ) : ( diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 3f7cf44cd2955..ee82d7c0bd6f5 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -90,7 +90,7 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -118,6 +118,8 @@ interface GitActionsControlProps { activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; compact?: boolean; + showActions?: boolean; + showChanges?: boolean; onOpenChanges?: () => void; } @@ -927,6 +929,8 @@ export default function GitActionsControl({ activeThreadRef, draftId, compact = false, + showActions = true, + showChanges = true, onOpenChanges, }: GitActionsControlProps) { const panelAnchorRef = useRef(null); @@ -956,7 +960,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThreadProjection = useThreadProjection( + const activeServerThreadProjection = useQuietThreadProjection( activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, ); const activeServerThread = @@ -1501,7 +1505,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} { + it.each([ + ["steady momentum decay", [40, 37, 34, 31.3, 28.8], true], + ["fingers still moving", [12, 18, 9, 15, 11], false], + ["fingers slowing unevenly", [30, 22, 20, 12, 11], false], + ["reversing", [10, 8, 6, 4, -2], false], + ["too few samples", [40, 37, 34], false], + ])("%s", (_name, deltas, expected) => { + expect(isMomentumTail(deltas)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx new file mode 100644 index 0000000000000..c020878b25363 --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -0,0 +1,189 @@ +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { cn } from "../lib/utils"; +import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../navigationHistory"; + +const SWIPE_TRIGGER_DISTANCE = 120; +// Movement needed before the gesture picks an axis; first events are often diagonal. +const SWIPE_AXIS_LOCK_DISTANCE = 8; +const MOMENTUM_SAMPLE_SIZE = 5; + +type SwipeBounds = { left: number; top: number; width: number; height: number }; +type SwipeGesture = + | { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number } + | { + kind: "tracking"; + direction: HistoryDirection; + distance: number; + deltas: number[]; + y: number; + bounds: SwipeBounds; + } + // Not a swipe, or one already decided: the rest of its events are ignored. + | { kind: "ignored" }; +type SwipeIndicatorState = { + direction: HistoryDirection; + progress: number; + y: number; + bounds: SwipeBounds; + tracking: boolean; +}; + +function readMainContentBounds(): SwipeBounds { + const main = document.querySelector('[data-slot="sidebar-inset"]'); + if (!main) return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; + const { left, top, width, height } = main.getBoundingClientRect(); + return { left, top, width, height }; +} + +function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean { + for ( + let element = target instanceof Element ? target : null; + element; + element = element.parentElement + ) { + if (element.scrollWidth <= element.clientWidth) continue; + const { overflowX } = getComputedStyle(element); + if (overflowX !== "auto" && overflowX !== "scroll") continue; + const canScroll = + deltaX < 0 + ? element.scrollLeft > 0 + : element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + if (canScroll) return true; + } + return false; +} + +function lockSwipeAxis( + pending: Extract, +): Exclude { + const { target, sumX, sumY } = pending; + if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; + if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" }; + if (canScrollHorizontally(target, sumX)) return { kind: "ignored" }; + const direction = sumX < 0 ? "back" : "forward"; + if (!canNavigateHistory(direction)) return { kind: "ignored" }; + return { + kind: "tracking", + direction, + distance: Math.abs(sumX), + deltas: [], + y: pending.y, + bounds: readMainContentBounds(), + }; +} + +/** + * macOS momentum after the fingers lift decays by a near-constant ratio per + * event; finger movement is noisier. Deltas are signed along the swipe. + */ +export function isMomentumTail(deltas: readonly number[]): boolean { + if (deltas.length < MOMENTUM_SAMPLE_SIZE) return false; + const recent = deltas.slice(-MOMENTUM_SAMPLE_SIZE); + if (recent.some((delta) => delta <= 0)) return false; + const ratios = recent.slice(1).map((delta, index) => delta / recent[index]!); + return ( + ratios.every((ratio) => ratio >= 0.7 && ratio < 1) && + Math.max(...ratios) - Math.min(...ratios) < 0.1 + ); +} + +/** Two-finger horizontal trackpad swipes go back and forward, like a browser. */ +export function HistorySwipeNavigation() { + const [swipe, setSwipe] = useState(null); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes + useEffect(() => { + let gesture: SwipeGesture | null = null; + const hideIndicator = () => + setSwipe((current) => (current === null ? null : { ...current, tracking: false })); + // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. + const release = () => { + if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { + navigateHistory(gesture.direction); + } + gesture = { kind: "ignored" }; + hideIndicator(); + }; + // Chromium brackets each trackpad scroll, momentum included, with these. + const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { + if (gesture?.kind === "ignored") gesture = null; + }); + const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { + if (gesture?.kind === "tracking") release(); + gesture = null; + }); + const onWheel = (event: WheelEvent) => { + if (gesture === null) { + gesture = + event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL + ? { kind: "ignored" } + : { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 }; + } + if (gesture.kind === "ignored") return; + if (gesture.kind === "pending") { + gesture.sumX += event.deltaX; + gesture.sumY += event.deltaY; + if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; + gesture = lockSwipeAxis(gesture); + if (gesture.kind === "ignored") return; + } else { + const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; + if (event.deltaX === 0 && event.deltaY === 0) return release(); + gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; + if (isMomentumTail(gesture.deltas)) return release(); + gesture.distance = Math.max(0, gesture.distance + delta); + } + setSwipe({ + direction: gesture.direction, + progress: Math.min(1, gesture.distance / SWIPE_TRIGGER_DISTANCE), + y: gesture.y, + bounds: gesture.bounds, + tracking: true, + }); + }; + window.addEventListener("wheel", onWheel, { capture: true, passive: true }); + return () => { + unsubscribeScrollBegin?.(); + unsubscribeScrollEnd?.(); + window.removeEventListener("wheel", onWheel, { capture: true }); + }; + }, []); + + return swipe === null ? null : ; +} + +// A circle pinned to the edge of the main content that peeks in up to half its width. +function SwipeIndicator({ direction, progress, y, bounds, tracking }: SwipeIndicatorState) { + const armed = progress >= 1; + const Icon = direction === "back" ? ArrowLeftIcon : ArrowRightIcon; + const hidden = 100 - progress * 50; + return ( + + ); +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 66696bc396e47..0d942f6db05d0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -609,3 +609,12 @@ export function GoogleSourceIcon({ : GoogleDriveIcon; return ; } + +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 98da50e213719..8e3b6f708fa66 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -94,6 +94,7 @@ import { useSplitPaneFocus } from "./chat/splitPane"; 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; @@ -246,17 +247,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 5abbd1b3f4894..0c53cae8b1e5c 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"; @@ -41,6 +44,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."; } @@ -52,6 +78,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"> & { @@ -79,7 +111,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") { @@ -118,12 +156,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; @@ -198,6 +238,7 @@ export function ServerUpdateAction({ installation, desktopAppUpdate = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", @@ -205,6 +246,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(); @@ -264,6 +306,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -351,6 +397,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/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dda009d48a463..f49692addceea 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -2,6 +2,7 @@ import { AuthOrchestrationOperateScope, type EnvironmentId, type ModelSelection, + type ScopedThreadRef, type ThreadId, type ThreadTabGroup, } from "@t3tools/contracts"; @@ -64,6 +65,7 @@ interface ChatHeaderProps { rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; } function resolveRenameCommit(input: { @@ -92,6 +94,7 @@ export const ChatHeader = memo(function ChatHeader({ rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, + onFindInThread, }: ChatHeaderProps) { const splitPaneFocus = useSplitPaneFocus(); const { closePane } = useSplitViewActions(); @@ -222,6 +225,7 @@ export const ChatHeader = memo(function ChatHeader({ projectCwd: activeProjectCwd, onStartRename: startRename, tabsSupported: threadTabGroup !== null, + 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 87b47a51a722b..735fb99cd6531 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -8,7 +8,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"; @@ -29,23 +29,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) => ( + )); @@ -143,13 +153,20 @@ export function NoticeText({ ); } -function NoticeCard({ item }: { readonly item: ComposerBannerStackItem }) { +function NoticeCard({ + item, + detached = false, +}: { + readonly item: ComposerBannerStackItem; + readonly detached?: boolean; +}) { const descriptionBelow = item.descriptionPlacement === "below"; const descriptionDetails = item.descriptionPlacement === "details"; 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 5e2e5196b2a95..bd76d0fc8163a 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"; // No importOriginal: ChatMarkdown and AssistantCitationChip import each other, so loading the // original from inside this factory waits on the mock it is building and never resolves. @@ -74,20 +75,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, @@ -98,6 +95,12 @@ function PendingRequestsProbe({ composerRef, setThreadError: () => {}, }); +} + +let requests: ReturnType; + +function PendingRequestsProbe(props: ProbeProps) { + const state = useProbeRequests(props); useLayoutEffect(() => { requests = state; }); @@ -139,6 +142,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/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/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/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bf00230d2708b..4d3b6eb1f0810 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1852,10 +1852,10 @@ describe("deriveMessagesTimelineRows", () => { const settled = rows({ resume: "completed", working: false }); expect(shape(settled)).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); @@ -1868,17 +1868,17 @@ describe("deriveMessagesTimelineRows", () => { }); expect(shape(expanded)).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "work-toggle", "assistant:launch-answer", "user:resume", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:resume-answer", ]); expect(shape(rows({ resume: "running", working: true }))).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", "working", @@ -1887,7 +1887,7 @@ describe("deriveMessagesTimelineRows", () => { expect(shape(rows({ resume: "failed", working: false }))).toEqual([ "user:launch", - "fold:Worked for 8.0s", + "fold:Worked for 8s", "assistant:launch-answer", "user:resume", "work", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 334d0d221e2e6..ae3f55067b5f4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -691,12 +691,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; - const pendingApprovalsByRequestId = new Map( - pendingApprovals - .filter((approval) => !heldApprovalRequestIds?.has(approval.requestId)) - .map((approval) => [approval.requestId, approval]), + const isVisiblePendingApproval = (approval: PendingApproval) => + !heldApprovalRequestIds?.has(approval.requestId); + const pendingApprovalRequestIds = new Set( + pendingApprovals.filter(isVisiblePendingApproval).map((approval) => approval.requestId), ); - const pendingApprovalRequestIds = new Set(pendingApprovalsByRequestId.keys()); const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { timelineEntries, pendingApprovalRequestIds, @@ -715,17 +714,19 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); - const unmatchedPendingApprovals = new Map(pendingApprovalsByRequestId); - if (unmatchedPendingApprovals.size > 0) { - for (const row of rows) { - const entries = - row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; - for (const entry of entries) { - const item = entry.projectedItem?.item; - if (item?.type === "approval_request") unmatchedPendingApprovals.delete(item.requestId); - } + const matchedApprovalRequestIds = new Set(); + for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) { + const entries = + row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; + for (const entry of entries) { + const item = entry.projectedItem?.item; + if (item?.type === "approval_request") matchedApprovalRequestIds.add(item.requestId); } } + const unmatchedPendingApprovals = pendingApprovals.filter( + (approval) => + isVisiblePendingApproval(approval) && !matchedApprovalRequestIds.has(approval.requestId), + ); // Run status/timestamps change per stream event; keep them out of shared row context. const runs = useStableHandoffRuns(runsProp); const minimapItems = deriveTimelineMinimapItems(rows); @@ -738,7 +739,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined; const showsEmptyState = rows.length === 0 && - unmatchedPendingApprovals.size === 0 && + unmatchedPendingApprovals.length === 0 && !isWorking && parentThreadLink === null && historyControls === undefined && @@ -1064,6 +1065,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; }; + // Built from props after the last hook so the compiler can memoize the row context; a map + // made before a hook would rebuild it, and re-render every row, on each streamed update. + const pendingApprovalsByRequestId = new Map( + pendingApprovals + .filter(isVisiblePendingApproval) + .map((approval) => [approval.requestId, approval]), + ); const sharedState: TimelineRowSharedState = { pendingApprovals: pendingApprovalsByRequestId, respondingRequestIds, @@ -1328,12 +1336,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ onGrow={onFooterGrow} > {footer} - {unmatchedPendingApprovals.size > 0 ? ( + {unmatchedPendingApprovals.length > 0 ? (
- {[...unmatchedPendingApprovals.values()].map((approval) => ( + {unmatchedPendingApprovals.map((approval) => ( ))} diff --git a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx index 39d390f077b29..42383966cdbe2 100644 --- a/apps/web/src/components/chat/OpenInPicker.browser.test.tsx +++ b/apps/web/src/components/chat/OpenInPicker.browser.test.tsx @@ -230,6 +230,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/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx index cdaae40802bcf..aa4f78043136d 100644 --- a/apps/web/src/components/chat/QueuedRunsControl.tsx +++ b/apps/web/src/components/chat/QueuedRunsControl.tsx @@ -6,7 +6,10 @@ import { InlineChipText } from "./InlineChipText"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; -import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; import type { ChatAttachment as ContractChatAttachment, EnvironmentId, @@ -325,11 +328,13 @@ export function QueuedRunsControl({
    {visibleItems.map((item, position) => { - const previewText = assistantCitationsToPlainText( - replaceComposerContextReferences(item.text, (reference) => - reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, - ), - ).trim(); + const previewText = isAttachmentOnlyBootstrapPrompt(item.text) + ? "" + : assistantCitationsToPlainText( + replaceComposerContextReferences(item.text, (reference) => + reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label, + ), + ).trim(); const isEditing = item.runId !== null && item.runId === props.editingRunId; return (
  1. void; +}): ThreadNowItem { + const unresponsive = input.stop.state === "unresponsive"; + const requestedAtMs = Date.parse(input.stop.requestedAt); + return { + id: `run-stop:${input.threadId}`, + icon: unresponsive ? ( + + ) : ( + + ), + title: unresponsive ? "Agent not responding to Stop" : "Stopping", + description: unresponsive + ? "It may be short on memory. Force stop kills its processes." + : undefined, + actions: ( + <> + formatDuration(Math.max(1_000, nowMs - requestedAtMs)), + true, + )} + className="tabular-nums" + /> + {unresponsive && input.canForceStop ? ( + + {input.forceStopping ? "Force stopping..." : "Force stop"} + + ) : null} + + ), + }; +} + /** Background commands and subagents, for the end of the timeline. */ export function backgroundWorkNowItem(input: { readonly threadId: ThreadId; 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 21cb352952be6..1a389c3c1b172 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -17,7 +17,7 @@ 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"; @@ -27,6 +27,17 @@ import { ThreadLinkedCheckouts } from "./ThreadLinkedCheckouts"; import { ThreadLinks } from "./ThreadLinks"; 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); @@ -75,14 +86,15 @@ interface ThreadDetailsPanelProps extends Pick< input: NewProjectScriptInput, ) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; - /** Set only while the Labs usage readout is on. */ - usage?: ThreadDetailsUsageInput | undefined; + 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 = { @@ -116,113 +128,200 @@ 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} - {density !== "essential" && props.showOpenInPicker ? ( - + {props.gitCwd && (show.branch || show.git) ? ( + +
    + {show.branch ? ( + + ) : null} + {show.git ? ( + + ) : null} +
    +
    ) : null} - {props.activeProjectScripts ? ( - + {density === "full" && !props.draftId && visible.servers ? ( + ) : null} -
    - - {props.gitCwd ? ( - -
    - {props.isGitRepo ? ( - - ) : null} - {props.activeProjectName ? ( - + + - ) : null} -
    -
    - ) : null} + + ) : null} - {density === "full" && !props.draftId ? ( - <> - - - - ) : null} + {density === "full" && !props.draftId && visible.automations ? ( + + ) : null} - {density === "full" && !props.draftId ? ( - - ) : null} + {density === "full" && !props.draftId && visible.lineage ? ( + + ) : 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 + + +
    +
    - {props.usage ? : 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", + )} >
    {stopping ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 4992d8e5a6f37..05faa2b9ac3bf 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -57,7 +57,6 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, - LoaderCircleIcon, MoreHorizontalIcon, PanelRightIcon, PlusIcon, @@ -98,6 +97,7 @@ import { Button } from "../ui/button"; 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, @@ -935,7 +935,7 @@ export function ThreadRelationshipsPanel(props: { onClick={() => void merge()} > {busyAction === "merge" ? ( - + ) : ( )} @@ -977,7 +977,7 @@ export function ThreadRelationshipsPanel(props: { } > {stoppingId === threadId ? ( - + ) : ( )} diff --git a/apps/web/src/components/chat/ThreadTimeline.tsx b/apps/web/src/components/chat/ThreadTimeline.tsx index 16d6f9af79367..3bf189aea5c03 100644 --- a/apps/web/src/components/chat/ThreadTimeline.tsx +++ b/apps/web/src/components/chat/ThreadTimeline.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { type ComponentProps, useLayoutEffect } from "react"; import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries"; import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic"; -import { useThreadVisibleTurnItems } from "../../state/entities"; +import { useTimelineVisibleTurnItems } from "../../state/entities"; import { peekRememberedThreadTimeline, rememberReadyThreadTimeline, @@ -14,7 +14,8 @@ import { MessagesTimeline } from "./MessagesTimeline"; /** * The timeline with the streamed text. ChatView reads the thread through its quiet * atoms, which hold through text deltas; this reads the live turn items, so a - * delta re-renders the timeline without re-rendering ChatView. + * reasoning delta re-renders the timeline without re-rendering ChatView. Streamed + * assistant text reaches only its own row, through useAssistantMessageText. */ export function ThreadTimeline({ pendingModelChange, @@ -36,7 +37,7 @@ export function ThreadTimeline({ syncing: boolean; detailLoading: boolean; }) { - const visibleTurnItems = useThreadVisibleTurnItems(threadDetailRef); + const visibleTurnItems = useTimelineVisibleTurnItems(threadDetailRef); const serverEntries = useIncrementalTimelineEntries(activeThreadKey, { ...entriesInput, visibleTurnItems: pendingModelChange diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 406db30f9c799..39fe69a24d239 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -9,9 +9,10 @@ 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"; +import { useAssistantMessageText, useProject, useThreadShell } from "../../state/entities"; import { MessageCopyButton } from "./MessageCopyButton"; import { AssistantCitationSource } from "./AssistantCitationSource"; import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic"; @@ -25,7 +26,13 @@ import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts" export function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + // The timeline holds a streaming message's row while only its text grows. + const liveText = useAssistantMessageText( + row.message.streaming ? ctx.threadRef : null, + row.projectedItem?.item.id, + ); + const text = liveText ?? row.message.text; + const messageText = text || (row.message.streaming ? "" : "(empty response)"); return ( <> @@ -347,6 +354,7 @@ function AssistantChangedFilesSectionInner({ diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index a26cf1b0942ec..da8e65b26444f 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -38,7 +38,7 @@ import { type LucideIcon, MessagesSquareIcon, Redo2Icon, - Undo2Icon, + PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; @@ -85,6 +85,10 @@ import { import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +// Drawn inside the media so dark screenshots keep an edge on the dark background. +const MEDIA_EDGE_CLASS_NAME = + "relative after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-black/9 dark:after:ring-white/12"; + const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; @@ -333,9 +337,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const chippedAttachmentIds = new Set( @@ -346,6 +348,27 @@ function UserTimelineRow({ row }: { row: Extract !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); + // Images or videos sent without words show as media, not as a bubble around them. + const mediaOnly = + !folded && + userMessage.attribution !== "guest" && + resolvedContext.text.trim() === "" && + userAudio.length === 0 && + unchippedFiles.length === 0 && + unknownAttachments.length === 0 && + regularImages.length + userVideos.length > 0; + // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot. + const loneImage = + mediaOnly && + userVideos.length === 0 && + regularImages.length === 1 && + regularImages[0]!.source?.kind !== "snap-shot"; + // Several images sent without words share one rounded shape, split by hairlines. + const collage = + mediaOnly && + userVideos.length === 0 && + regularImages.length > 1 && + regularImages.every((image) => image.source?.kind !== "snap-shot"); const annotationRecordIds = resolvedContext.records .filter((record) => record.kind === "preview-annotation") .map((record) => record.contextId); @@ -475,8 +498,10 @@ function UserTimelineRow({ row }: { row: Extract @@ -508,15 +533,45 @@ function UserTimelineRow({ row }: { row: Extract {(regularImages.length > 0 || userVideos.length > 0) && ( -
    - {regularImages.map((image) => ( +
    + {regularImages.map((image, index) => (
    3 && + regularImages.length % 2 === 1 && + index === regularImages.length - 1 && + "col-span-2", + ) + : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", )} > {image.previewUrl ? ( @@ -538,11 +593,23 @@ function UserTimelineRow({ row }: { row: Extract ) : ( -
    +
    {image.name}
    )} @@ -753,7 +820,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 3c969f21942b3..028160dfb3905 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -635,9 +635,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} + diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index c8f55665113ac..90cc7533c0b51 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -238,7 +238,7 @@ export function DevicePanel(props: { environmentId={environmentId} device={activeDevice} hostLabel={activeHost?.label ?? "Device host"} - simlock={activeHost?.kind === "simlock"} + hostKind={activeHost?.kind} hostDiagnostics={ diagnostics.find(({ hostId }) => hostId === activeDevice.hostId)?.detail } diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 9d7b30c0456a7..29b46e99319a4 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -119,7 +119,7 @@ export function platformSetupStatus(state: DeviceServiceState, platform: DeviceP ready: true, message: remoteHost?.kind === "eas" - ? `${remoteHost.label} starts iOS Simulators in the cloud with EAS Simulator.` + ? `${remoteHost.label} starts ${platform === "ios" ? "iOS Simulators" : "Android emulators"} in the cloud with EAS Simulator.` : platform === "ios" ? `Xcode and iOS Simulator are available${location}.` : `The Android SDK and Emulator are available${location}.`, diff --git a/apps/web/src/components/device/DeviceWorkspace.tsx b/apps/web/src/components/device/DeviceWorkspace.tsx index b57adae023269..08ce51c1c548c 100644 --- a/apps/web/src/components/device/DeviceWorkspace.tsx +++ b/apps/web/src/components/device/DeviceWorkspace.tsx @@ -1,4 +1,4 @@ -import type { DeviceSummary, EnvironmentId } from "@t3tools/contracts"; +import type { DeviceHostSummary, DeviceSummary, EnvironmentId } from "@t3tools/contracts"; import { useRef, useState } from "react"; import { captureDeviceScreenshot, @@ -16,8 +16,7 @@ export function DeviceWorkspace(props: { environmentId: EnvironmentId; device: DeviceSummary; hostLabel: string; - /** Leased from a Simlock pool: serve-sim input, no host commands. */ - simlock?: boolean; + hostKind: DeviceHostSummary["kind"] | undefined; hostDiagnostics: string | undefined; visible: boolean; onFloat: () => void; @@ -31,7 +30,13 @@ export function DeviceWorkspace(props: { // A capture always finishes, even if the panel hides or closes meanwhile; the user asked for it. const capturing = useRef(false); const access = useDeviceHubAccess(props.environmentId, props.device.hostId); - const controls = useDeviceControls({ ...props, access }); + // Settings and tools run commands on the device's own machine, which EAS and Simlock do not expose. + const runsHostCommands = props.hostKind === "local" || props.hostKind === "ssh"; + const controls = useDeviceControls({ + ...props, + access, + visible: props.visible && runsHostCommands, + }); const saveScreenshot = () => { if (!access || capturing.current || !props.visible) return; capturing.current = true; @@ -82,12 +87,13 @@ export function DeviceWorkspace(props: { deviceId={props.device.id} hostId={props.device.hostId} visible={props.visible} - serveSimOnly={props.simlock === true} + serveSimOnly={props.hostKind === "simlock"} axOverlay={axOverlay} allowPhoneView renderControls={(view) => ( )} /> diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index b683e13a8f6ee..69dafc2dd7823 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -193,6 +193,7 @@ function reviewFileQueries({ request: { cwd, ...(baseRef ? { baseRef } : {}), + ...(source.kind === "commit" ? { commitSha: source.id } : {}), ignoreWhitespace, file: { path: file.path, diff --git a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx index 9785a6764b2b6..3915ecce9a4ca 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.browser.test.tsx @@ -1,3 +1,4 @@ +import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { describe, expect, it, vi } from "vite-plus/test"; import "~/index.css"; @@ -34,3 +35,109 @@ describe("HtmlRenderDocument", () => { expect(onContentHeight).toHaveBeenCalledTimes(1); }); }); + +it("keeps the first and last selected card rings inside the fitted renderer frame", async () => { + const html = injectHtmlRenderBootstrap(`
    + + + +
    `); + const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); + using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + const container = document.createElement("div"); + container.style.width = "728px"; + const reports = new Map< + string, + { + selected: boolean; + ring: number; + left: number; + right: number; + top: number; + bottom: number; + pageHeight: number; + viewportHeight: number; + viewportWidth: number; + scrollWidth: number; + } + >(); + const messages = new AbortController(); + using _messages = { [Symbol.dispose]: () => messages.abort() }; + window.addEventListener( + "message", + (event) => { + const frame = container.querySelector("iframe"); + if (event.source === frame?.contentWindow && event.data?.type === "selected-card-bounds") { + reports.set(event.data.id, event.data); + } + }, + { signal: messages.signal }, + ); + const contentHeight = vi.fn((height: number) => { + container.style.setProperty("--content-height", `${height}px`); + }); + await using screen = await render( + <> + + + , + { container }, + ); + + await vi.waitFor(() => expect(contentHeight).toHaveBeenCalled()); + const frame = screen.getByTitle("Edge cards").element() as HTMLIFrameElement; + await vi.waitFor(() => { + expect(frame.getBoundingClientRect().height).toBe(contentHeight.mock.lastCall?.[0]); + }); + + for (const id of ["first", "last"]) { + frame.contentWindow?.postMessage({ type: "select-card", id }, "*"); + await vi.waitFor(() => expect(reports.get(id)?.selected).toBe(true)); + const bounds = reports.get(id)!; + expect(bounds.ring).toBe(5); + expect(bounds.viewportWidth).toBe(728); + expect(bounds.pageHeight).toBe(contentHeight.mock.lastCall?.[0]); + expect(bounds.viewportHeight).toBe(bounds.pageHeight); + expect(bounds.left).toBeGreaterThanOrEqual(0); + expect(bounds.top).toBeGreaterThanOrEqual(0); + expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth); + expect(bounds.bottom).toBeLessThanOrEqual(bounds.viewportHeight); + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.viewportWidth); + } +}); diff --git a/apps/web/src/components/files/FileExplorerPane.tsx b/apps/web/src/components/files/FileExplorerPane.tsx new file mode 100644 index 0000000000000..c13a814f63051 --- /dev/null +++ b/apps/web/src/components/files/FileExplorerPane.tsx @@ -0,0 +1,90 @@ +import { type CSSProperties, type ReactNode, useState } from "react"; + +import { RightPanelResizeHandle } from "~/components/preview/RightPanelResizeHandle"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "~/hooks/useResizableWidth"; +import { observeResize } from "~/lib/observeResize"; +import { cn } from "~/lib/utils"; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 560; +const FILE_CONTENT_MIN_WIDTH = 256; + +export function FileExplorerPane({ + resizable, + children, +}: { + resizable: boolean; + children: ReactNode; +}) { + const [maxWidth, setMaxWidth] = useState(MAX_WIDTH); + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: "t3code.fileExplorerWidth", + defaultWidth: 256, + minWidth: MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( + + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 30b27a519a73d..2fdddae15e154 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -87,6 +87,7 @@ import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; import { PreviewImage } from "./PreviewImage"; import FileBrowserPanel from "./FileBrowserPanel"; +import { FileExplorerPane } from "./FileExplorerPane"; import { FileBreadcrumbs } from "./FileBreadcrumbs"; import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; @@ -1569,14 +1570,7 @@ export default function FilePreviewPanel({ ) : null}
    {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/guest/GuestPage.tsx b/apps/web/src/components/guest/GuestPage.tsx index d84b55c2a2c8e..385398ed65a16 100644 --- a/apps/web/src/components/guest/GuestPage.tsx +++ b/apps/web/src/components/guest/GuestPage.tsx @@ -1,6 +1,7 @@ import { EnvironmentId, GUEST_MESSAGE_MAX_LENGTH, + GUEST_HISTORY_ROUTE, GUEST_NAME_MAX_LENGTH, GUEST_ROUTE_PREFIX, GUEST_SHARE_PARAM, @@ -8,6 +9,7 @@ import { type GuestRedeemRequest, type GuestShareView, type GuestThreadModel, + OrchestrationV2ThreadHistoryPage, type OrchestrationV2ThreadProjection, type ThreadGuestMessage, type ThreadGuestMessageStatus, @@ -46,7 +48,7 @@ import { GuestMessageAttachments } from "../chat/GuestMessageAttachments"; import { HostImageUrlContext } from "../chat/hostImageUrl"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { GuestMessageName, guestBubbleClassName } from "../chat/guestMessageStyle"; -import { MessagesTimeline } from "../chat/MessagesTimeline"; +import { MessagesTimeline, type MessagesTimelineHistoryControls } from "../chat/MessagesTimeline"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { getDisplayModelName, getTriggerDisplayModelName } from "../chat/providerIconUtils"; import { ScrollToEndButton } from "../chat/TimelineOverlays"; @@ -131,6 +133,32 @@ function takeGuestEntry(): GuestEntry { } const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ message: Schema.String })); +const decodeHistoryPage = Schema.decodeUnknownOption( + Schema.toCodecJson(OrchestrationV2ThreadHistoryPage), +); + +/** Resolves to the page, or to a message the guest can read. */ +async function fetchGuestHistoryPage( + shareId: string, + cursor: string, +): Promise { + const failed = "Could not load earlier turns. Try again."; + try { + const response = await fetch( + `${GUEST_HISTORY_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&cursor=${encodeURIComponent(cursor)}`, + ); + const json: unknown = await response.json().catch(() => null); + if (!response.ok) { + return Option.match(decodeErrorBody(json), { + onNone: () => failed, + onSome: ({ message }) => message, + }); + } + return Option.getOrElse(decodeHistoryPage(json), () => failed); + } catch { + return "Could not reach the server."; + } +} /** Resolves to null on success, or to a message the guest can read. */ async function postGuest( @@ -241,6 +269,32 @@ function GuestSession({ shareId }: { readonly shareId: string }) { // missing, invalid, or ended session. Network drops reconnect on their own. const [rejected, setRejected] = useState(false); + const [historyLoad, setHistoryLoad] = useState<{ + readonly cursor: string; + readonly error: string | null; + } | null>(null); + const historyCursor = stream.historyCursor; + // A load for an older cursor is stale: a page or a reconnect snapshot moved the cursor on. + const currentLoad = historyLoad?.cursor === historyCursor ? historyLoad : null; + const loadingHistory = currentLoad !== null && currentLoad.error === null; + const historyError = currentLoad?.error ?? null; + const loadEarlier = async () => { + if (historyCursor === null || loadingHistory) return; + setHistoryLoad({ cursor: historyCursor, error: null }); + const result = await fetchGuestHistoryPage(shareId, historyCursor); + if (typeof result === "string") setHistoryLoad({ cursor: historyCursor, error: result }); + else dispatch({ type: "history-page", cursor: historyCursor, page: result }); + }; + const historyControls: MessagesTimelineHistoryControls | undefined = + historyCursor === null + ? undefined + : { + hasMoreHistory: true, + loading: loadingHistory, + error: historyError, + onLoadEarlier: () => void loadEarlier(), + }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sets document.title to the shared thread's title useEffect(() => { if (stream.view) document.title = stream.view.title; @@ -273,6 +327,7 @@ function GuestSession({ shareId }: { readonly shareId: string }) { view={stream.view} projection={stream.projection} awaitingRowIds={stream.awaitingRowIds} + historyControls={historyControls} /> ); } @@ -358,10 +413,12 @@ function GuestThread({ view: { shareId, title, expiresAt, messages, model }, projection, awaitingRowIds, + historyControls, }: { readonly view: GuestShareView; readonly projection: OrchestrationV2ThreadProjection; readonly awaitingRowIds: GuestStreamState["awaitingRowIds"]; + readonly historyControls: MessagesTimelineHistoryControls | undefined; }) { // No ":" so the timeline finds no environment thread behind the key. const threadKey = `guest-${shareId}`; @@ -469,6 +526,7 @@ function GuestThread({ onFileDownload={openFile} renderHtmlRender={renderHtmlRender} turnItemSource={turnItemSource} + historyControls={historyControls} activeThreadEnvironmentId={GUEST_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/components/guest/guestStream.logic.test.ts b/apps/web/src/components/guest/guestStream.logic.test.ts index fbdf9ce1927c2..674227dc171ff 100644 --- a/apps/web/src/components/guest/guestStream.logic.test.ts +++ b/apps/web/src/components/guest/guestStream.logic.test.ts @@ -112,6 +112,45 @@ describe("applyGuestStreamEvent", () => { expect(apply([itemEvent(2, reply("a1", "Changed"))], once)).toBe(once); }); + it("merges older pages in front, only for the cursor the thread is at", () => { + const windowed: GuestStreamEvent = { + type: "thread", + item: { + kind: "snapshot", + snapshotSequence: 1, + projection, + historyCursor: "cursor-1", + hasMoreHistory: true, + }, + }; + const page = (id: string, nextCursor: string | null) => ({ + snapshotSequence: 1, + items: [ + { + position: 0, + visibility: "local" as const, + sourceThreadId: projection.thread.id, + sourceItemId: TurnItemId.make(id), + item: reply(id, "Earlier"), + }, + ], + nextCursor, + hasMoreHistory: nextCursor !== null, + }); + const live = apply([windowed, itemEvent(2, reply("a1", "Hi"))]); + expect(live.historyCursor).toBe("cursor-1"); + // A page fetched before a reconnect moved the cursor changes nothing. + const stale = { type: "history-page" as const, cursor: "cursor-0", page: page("old", null) }; + expect(applyGuestStreamEvent(live, stale)).toBe(live); + const merged = applyGuestStreamEvent(live, { + type: "history-page", + cursor: "cursor-1", + page: page("o1", null), + }); + expect(itemIds(merged)).toEqual(["o1", "a1"]); + expect(merged.historyCursor).toBeNull(); + }); + it("replaces the thread on a new snapshot, as after a reconnect", () => { const before = apply([snapshot(1), itemEvent(2, reply("a1", "Hi")), { type: "ended" }]); const after = apply([snapshot(1)], { ...before, ended: false }); diff --git a/apps/web/src/components/guest/guestStream.logic.ts b/apps/web/src/components/guest/guestStream.logic.ts index 0b37d66eeec84..03eafb6d348ee 100644 --- a/apps/web/src/components/guest/guestStream.logic.ts +++ b/apps/web/src/components/guest/guestStream.logic.ts @@ -9,11 +9,13 @@ import { type GuestMessageId, type GuestShareView, type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, type ThreadGuestMessage, } from "@t3tools/contracts"; import { applyOrchestrationV2ProjectionEvent } from "@t3tools/client-runtime/state/orchestration-v2-projection"; +import { mergeOlderHistoryIntoProjection } from "@t3tools/client-runtime/state/thread-history-merge"; import { boundedSnapshotProjection } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -25,6 +27,8 @@ export interface GuestStreamState { readonly sequence: number; /** Set while the snapshot is a window of a longer history, as the owner's client tracks it. */ readonly partial: { readonly latestLocalTurnOrdinal: number | null } | null; + /** Where "Load earlier turns" continues, or null once the whole thread is loaded. */ + readonly historyCursor: string | null; readonly ended: boolean; /** * Messages this page saw waiting that have no transcript row yet. Survives reconnects, and a @@ -38,6 +42,7 @@ export const initialGuestStreamState: GuestStreamState = { projection: null, sequence: 0, partial: null, + historyCursor: null, ended: false, awaitingRowIds: new Set(), }; @@ -76,16 +81,32 @@ const decodeGuestStreamEvent = Schema.decodeUnknownOption( export const parseGuestStreamEvent = (data: string): GuestStreamEvent | null => Option.getOrNull(decodeGuestStreamEvent(data)); +/** A page of older turns, fetched from `cursor`. */ +export type GuestHistoryPageLoaded = { + readonly type: "history-page"; + readonly cursor: string; + readonly page: OrchestrationV2ThreadHistoryPage; +}; + /** * Applies the owner's thread stream the way the owner's client does (see * EnvironmentThreadState in client-runtime): a snapshot replaces the thread, and later events - * pass through the same projection reducer, once each. + * pass through the same projection reducer, once each. Older pages merge in front, as they do in + * the owner's view. */ export function applyGuestStreamEvent( state: GuestStreamState, - event: GuestStreamEvent, + event: GuestStreamEvent | GuestHistoryPageLoaded, ): GuestStreamState { switch (event.type) { + case "history-page": + // A snapshot since the request replaced the thread and its cursor. + if (state.projection === null || state.historyCursor !== event.cursor) return state; + return { + ...state, + projection: mergeOlderHistoryIntoProjection(state.projection, event.page.items), + historyCursor: event.page.hasMoreHistory ? event.page.nextCursor : null, + }; case "view": // The page follows the share it names; a view of another one means the link changed. if (state.view !== null && state.view.shareId !== event.view.shareId) { @@ -108,6 +129,7 @@ export function applyGuestStreamEvent( item.hasMoreHistory === true || (item.historyCursor ?? null) !== null ? { latestLocalTurnOrdinal: item.latestLocalTurnOrdinal ?? null } : null, + historyCursor: item.hasMoreHistory === true ? (item.historyCursor ?? null) : null, awaitingRowIds: withoutPlaced( state.awaitingRowIds, projection.visibleTurnItems.map((row) => row.item), 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/guide/useGuideDiffFiles.browser.test.tsx b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx new file mode 100644 index 0000000000000..cdb7d4099e5c7 --- /dev/null +++ b/apps/web/src/components/pullRequest/guide/useGuideDiffFiles.browser.test.tsx @@ -0,0 +1,70 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, ProjectId, type PullRequestDiffResult } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../../test/render"; + +const state = vi.hoisted(() => ({ + 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/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 701af39c1bd48..e4da7d0634021 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -59,6 +59,7 @@ import { resolveOfficialAcpRegistryIconUrl } from "./AcpRegistryIcon"; import { AddManagedCodexAccountDialog } from "./CodexSetupSection"; import { MultipleAccountsNote } from "./MultipleAccountsNote"; import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; +import { useSecretSources } from "./SecretSourceInput"; /** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */ function slugifyLabel(value: string): string { @@ -165,6 +166,7 @@ export function AddProviderInstanceDialog({ onCreated, }: AddProviderInstanceDialogProps) { const settings = useEnvironmentSettings(environmentId); + const secretSources = useSecretSources(environmentId); const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; @@ -594,6 +596,7 @@ export function AddProviderInstanceDialog({ ) : null} 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..33192bbaab71f --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.test.ts @@ -0,0 +1,61 @@ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import { describe, expect, it } from "vite-plus/test"; + +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; + +describe("adding usage limit sources", () => { + it.each([ + ["http://proxy.test:8318/hub-a", "http://proxy.test:8318/hub-b"], + ["localhost:8318", "localhost:9318"], + ])("retains saved configurations for %s and %s", (firstUrl, secondUrl) => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + const sources = [ + [firstUrl, "First hub", "first-key"], + [secondUrl, "Second hub", "second-key"], + [`${secondUrl}/extra`, "Third hub", "third-key"], + ] as const; + for (const [url, label, managementKey] of sources) { + const id = sourceIdFromUrl(url, settings.usageLimitSources); + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [id]: { kind: "cliproxy", url, label, managementKey, enabled: true }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual( + sources.map(([url, label, managementKey]) => ({ + kind: "cliproxy", + url, + label, + managementKey, + enabled: true, + })), + ); + }); + + it("updates a saved hub when its URL is added again", () => { + let settings: ServerSettings = DEFAULT_SERVER_SETTINGS; + for (const managementKey of ["old-key", "new-key"]) { + const url = "http://proxy.test:8318/hub-a"; + settings = applyServerSettingsPatch(settings, { + usageLimitSources: { + [sourceIdFromUrl(url, settings.usageLimitSources)]: { + kind: "cliproxy", + url, + managementKey, + enabled: true, + }, + }, + }); + } + expect(Object.values(settings.usageLimitSources)).toEqual([ + { + kind: "cliproxy", + url: "http://proxy.test:8318/hub-a", + managementKey: "new-key", + enabled: true, + }, + ]); + }); +}); diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts new file mode 100644 index 0000000000000..67ccabdefefc9 --- /dev/null +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.logic.ts @@ -0,0 +1,24 @@ +import { type UnifiedSettings, UsageLimitSourceId } from "@t3tools/contracts"; + +export function sourceIdFromUrl( + url: string, + sources: UnifiedSettings["usageLimitSources"], +): UsageLimitSourceId { + const saved = Object.entries(sources).find(([, source]) => source.url === url); + if (saved) return UsageLimitSourceId.make(saved[0]); + let host = url; + try { + host = new URL(url).host; + } catch {} + const slug = host + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + // Settings merge by ID, so adding a different hub must not replace an existing entry. + const base = `cliproxy-${slug || "hub"}`; + let id = base; + for (let suffix = 2; id in sources; suffix += 1) { + id = `${base}-${suffix}`; + } + return UsageLimitSourceId.make(id); +} diff --git a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx index cc150016d73ce..e3abd8b951067 100644 --- a/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx +++ b/apps/web/src/components/settings/AddUsageLimitSourceDialog.tsx @@ -1,4 +1,4 @@ -import { type EnvironmentId, UsageLimitSourceId } from "@t3tools/contracts"; +import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts"; import { useState } from "react"; import { useUpdateEnvironmentSettings } from "../../hooks/useSettings"; @@ -13,32 +13,23 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { sourceIdFromUrl } from "./AddUsageLimitSourceDialog.logic"; import { SecretInput } from "./SecretInput"; import { Label } from "../ui/label"; -function sourceIdFromUrl(url: string): UsageLimitSourceId { - let host = url; - try { - host = new URL(url).host; - } catch {} - const slug = host - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return UsageLimitSourceId.make(`cliproxy-${slug || "hub"}`); -} - /** Send the management key once to server secret storage; settings retain only a redaction marker. */ export function AddUsageLimitSourceDialog({ open, onOpenChange, environmentId, environmentLabel, + sources, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly environmentId: EnvironmentId; readonly environmentLabel: string; + readonly sources: UnifiedSettings["usageLimitSources"]; }) { const updateSettings = useUpdateEnvironmentSettings(environmentId); const [label, setLabel] = useState(""); @@ -55,7 +46,7 @@ export function AddUsageLimitSourceDialog({ const save = () => { if (!canSave) return; - const id = sourceIdFromUrl(trimmedUrl); + const id = sourceIdFromUrl(trimmedUrl, sources); updateSettings({ usageLimitSources: { [id]: { diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index 9fcbf97ac6185..b49f152944087 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -35,6 +35,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", @@ -276,6 +277,7 @@ export function AppearanceSettingsPanel() {
    } /> + +
    {authActive ? ( <> {waitingControl} @@ -1002,6 +1002,16 @@ function ManagedCodexSetup({ Use a different account ) : null} + {allowExistingCli ? ( + + ) : null} )}
    diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 613074b2d914b..0fcea4fa5de00 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -48,7 +48,7 @@ function formatCount(value: number): string { function formatDuration(value: number): string { if (value < 1_000) return `${Math.round(value)} ms`; - return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)} s`; + return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2).replace(/\.0+$/, "")} s`; } function formatBytes(value: number): string { @@ -438,10 +438,10 @@ const RESOURCE_HISTORY_WINDOWS = [ ] as const; function formatCpuTime(seconds: number): string { - if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`; + if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0+$/, "")}s`; const minutes = seconds / 60; - if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; - return `${(minutes / 60).toFixed(2)}h`; + if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2).replace(/\.0+$/, "")}m`; + return `${(minutes / 60).toFixed(2).replace(/\.0+$/, "")}h`; } function formatShortProcessName(command: string): string { diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 1f51e20c0f839..ce3f68eac49d0 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -18,7 +18,6 @@ export function LabsSettingsPanel() { - {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -70,26 +69,6 @@ function BoringSidebarMultilineRow() { ); } -function ThreadDetailsUsageRow() { - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); - return ( - - updateSettings({ threadDetailsUsageEnabled: Boolean(checked) }) - } - aria-label="Usage in thread details" - /> - } - /> - ); -} - /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index c681f78765178..143d64d5c1bdf 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ServerProviderModel } from "@t3tools/contracts"; +import { SecretProviderId, type ServerProviderModel } from "@t3tools/contracts"; import { deriveProviderModelsForDisplay, + nextProviderEnvironmentWithFieldSecret, nextProviderEnvironmentWithFieldValue, } from "./ProviderInstanceCard"; @@ -75,6 +76,20 @@ describe("provider environment helpers", () => { ).toEqual([{ name: "CURSOR_API_KEY", value: "new-key", sensitive: true }]); }); + it("points a stored provider secret at a secret provider and drops the stored value", () => { + const secret = { + provider: SecretProviderId.make("personal"), + reference: "op://Private/Cursor/credential", + }; + const stored = [{ name: "CURSOR_API_KEY", value: "", sensitive: true, valueRedacted: true }]; + const referenced = nextProviderEnvironmentWithFieldSecret(stored, cursorApiKeyField, secret); + + expect(referenced).toEqual([{ name: "CURSOR_API_KEY", value: secret, sensitive: true }]); + expect( + nextProviderEnvironmentWithFieldSecret(referenced, cursorApiKeyField, undefined), + ).toEqual([]); + }); + it("applies the secure field default when replacing an existing non-sensitive value", () => { expect( nextProviderEnvironmentWithFieldValue( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 88361eac68bc8..d8b5c71fc0f98 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -15,7 +15,7 @@ import { import { Lock as LockGlyph, LockOpen } from "lucide"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useState, type ReactElement, type ReactNode } from "react"; +import { Fragment, useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, resolveProviderInstanceEnabled, @@ -26,6 +26,7 @@ import { type EnvironmentId, type ProjectId, type ProviderDriverKind, + type SecretReference, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -43,7 +44,7 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { MorphIcon } from "~/components/MorphIcon"; import { DraftInput } from "../ui/draft-input"; -import { SecretInput } from "./SecretInput"; +import { SecretSourceInput, type SecretSource } from "./SecretSourceInput"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -98,7 +99,7 @@ const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariable type EnvironmentDraftRow = { readonly id: string; readonly name: string; - readonly value: string; + readonly value: ProviderInstanceEnvironmentVariable["value"]; readonly sensitive: boolean; readonly valueRedacted?: boolean; }; @@ -119,6 +120,14 @@ function makeEnvironmentDraftRow( const toEnvironmentDraftRows = (environment: ReadonlyArray) => environment.map(makeEnvironmentDraftRow); +function environmentValuesEqual( + left: ProviderInstanceEnvironmentVariable["value"], + right: ProviderInstanceEnvironmentVariable["value"], +): boolean { + if (typeof left === "string" || typeof right === "string") return left === right; + return left.provider === right.provider && left.reference === right.reference; +} + function providerEnvironmentsEqual( left: ReadonlyArray, right: ReadonlyArray, @@ -130,7 +139,7 @@ function providerEnvironmentsEqual( return ( other !== undefined && variable.name === other.name && - variable.value === other.value && + environmentValuesEqual(variable.value, other.value) && variable.sensitive === other.sensitive && variable.valueRedacted === other.valueRedacted ); @@ -247,12 +256,28 @@ export function nextProviderEnvironmentWithFieldValue( return next; } +export function nextProviderEnvironmentWithFieldSecret( + environment: ReadonlyArray | undefined, + field: ProviderEnvironmentField, + secret: SecretReference | undefined, +): ReadonlyArray { + const others = providerEnvironmentWithoutNames(environment, new Set([field.name])); + return secret === undefined + ? others + : [...others, { name: field.name, value: secret, sensitive: true }]; +} + function ProviderEnvironmentFieldRow(props: { readonly field: ProviderEnvironmentField; readonly variable: ProviderInstanceEnvironmentVariable | undefined; readonly idPrefix: string; readonly onCommit: (field: ProviderEnvironmentField, value: string) => void; + readonly onSecretChange: ( + field: ProviderEnvironmentField, + secret: SecretReference | undefined, + ) => void; readonly onRemove: (field: ProviderEnvironmentField) => void; + readonly secretSources: ReadonlyArray; }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); @@ -269,13 +294,13 @@ function ProviderEnvironmentFieldRow(props: { size="sm" className="min-w-0 flex-1" autoComplete="off" - value={value} + value={typeof value === "string" ? value : ""} onCommit={(next) => props.onCommit(props.field, next)} placeholder={props.field.placeholder} spellCheck={false} /> ) : ( - props.onSecretChange(props.field, secret)} /> )} {props.variable ? ( @@ -306,6 +334,7 @@ function ProviderEnvironmentFieldRow(props: { export function ProviderEnvironmentSection(props: { readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; + readonly secretSources?: ReadonlyArray; }) { const [storedDraft, setDraft] = useState(() => seedRowsDraft(props.environment, toEnvironmentDraftRows), @@ -328,7 +357,7 @@ export function ProviderEnvironmentSection(props: { if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) { if ( name.length > 0 || - row.value.length > 0 || + row.value !== "" || row.sensitive !== true || row.valueRedacted !== undefined ) { @@ -357,6 +386,16 @@ export function ProviderEnvironmentSection(props: { publishRows(nextRows); }; + const setVariableSecret = (id: string, secret: SecretReference | undefined) => { + const nextRows = rows.map((row) => { + if (row.id !== id) return row; + const { valueRedacted: _valueRedacted, ...rest } = row; + return { ...rest, value: secret ?? "" }; + }); + setRows(nextRows); + publishRows(nextRows); + }; + const removeVariable = (id: string) => { const nextRows = rows.filter((row) => row.id !== id); setRows(nextRows); @@ -387,76 +426,20 @@ export function ProviderEnvironmentSection(props: { > {rows.length > 0 ? (
    - {rows.map((variable, index) => ( -
    + {rows.map((variable, index) => { + const nameInput = ( updateVariable(variable.id, { name: name.trim() })} placeholder="VARIABLE_NAME" spellCheck={false} aria-label={`Environment variable name ${index + 1}`} /> - - = - - {variable.sensitive ? ( - updateVariable(variable.id, { value })} - placeholder="value" - configured={variable.valueRedacted === true} - spellCheck={false} - aria-label={`Environment variable value ${index + 1}`} - /> - ) : ( - updateVariable(variable.id, { value })} - autoComplete="off" - placeholder="value" - spellCheck={false} - aria-label={`Environment variable value ${index + 1}`} - /> - )} - - { - const sensitive = !variable.sensitive; - updateVariable(variable.id, { - sensitive, - ...(sensitive && variable.valueRedacted === undefined - ? {} - : { valueRedacted: sensitive ? variable.valueRedacted : false }), - }); - }} - aria-pressed={variable.sensitive} - aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} - > - - - } - /> - - {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} - - + ); + const removeButton = ( -
    - ))} + ); + const sensitiveToggle = + typeof variable.value === "string" ? ( + + { + const sensitive = !variable.sensitive; + updateVariable(variable.id, { + sensitive, + ...(sensitive && variable.valueRedacted === undefined + ? {} + : { valueRedacted: sensitive ? variable.valueRedacted : false }), + }); + }} + aria-pressed={variable.sensitive} + aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} + > + + + } + /> + + {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} + + + ) : null; + const layout = (source: ReactNode, value: ReactNode) => ( +
    +
    + {nameInput} + {source === null ? null :
    {source}
    } + {removeButton} +
    +
    + {value} + {sensitiveToggle} +
    +
    + ); + return ( + + {variable.sensitive ? ( + updateVariable(variable.id, { value })} + placeholder="value" + configured={variable.valueRedacted === true} + spellCheck={false} + aria-label={`Environment variable value ${index + 1}`} + sources={props.secretSources ?? []} + onSecretChange={(secret) => setVariableSecret(variable.id, secret)} + > + {(parts) => layout(parts.source, parts.value)} + + ) : ( + layout( + null, + updateVariable(variable.id, { value })} + autoComplete="off" + placeholder="value" + spellCheck={false} + aria-label={`Environment variable value ${index + 1}`} + />, + ) + )} + + ); + })}

    Sensitive values are stored separately and never returned to the app.

    @@ -584,6 +653,7 @@ interface ProviderInstanceCardProps { readonly isUpdating?: boolean | undefined; readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; readonly environmentId?: EnvironmentId | undefined; + readonly secretSources?: ReadonlyArray; readonly projectOverride?: ProjectOverrideControl | undefined; readonly acpProjects?: | ReadonlyArray<{ @@ -595,6 +665,7 @@ interface ProviderInstanceCardProps { } const EMPTY_ACP_PROJECTS: NonNullable = []; +const NO_SECRET_SOURCES: ReadonlyArray = []; const NO_CUSTOM_MODELS: ReadonlyArray = []; /** Unknown drivers stay read-only to preserve fork config. projectOverride switches affect only the @@ -627,6 +698,7 @@ export function ProviderInstanceCard({ onAcceptUrlAuth, environmentId, acpProjects = EMPTY_ACP_PROJECTS, + secretSources = NO_SECRET_SOURCES, projectOverride, }: ProviderInstanceCardProps) { const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance); @@ -781,6 +853,12 @@ export function ProviderInstanceCard({ const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => { updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value)); }; + const updateEnvironmentFieldSecret = ( + field: ProviderEnvironmentField, + secret: SecretReference | undefined, + ) => { + updateEnvironment(nextProviderEnvironmentWithFieldSecret(instance.environment, field, secret)); + }; const removeEnvironmentField = (field: ProviderEnvironmentField) => { updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name]))); }; @@ -1182,7 +1260,9 @@ export function ProviderInstanceCard({ variable={readProviderEnvironmentVariable(instance.environment, field.name)} idPrefix={`provider-instance-${instanceId}`} onCommit={updateEnvironmentField} + onSecretChange={updateEnvironmentFieldSecret} onRemove={removeEnvironmentField} + secretSources={secretSources} /> ))}
    @@ -1225,6 +1305,7 @@ export function ProviderInstanceCard({
    {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index d99fa2240a0d2..ca40e1113c8b8 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentId, ProviderDriverKind, ProviderInstanceId, + type ProviderAuthState, type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; @@ -17,6 +18,8 @@ import { act, render } from "../../../test/render"; const state = vi.hoisted(() => ({ 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(), @@ -221,6 +227,7 @@ async function setup() { state.canManageProviders = true; state.canWriteSettings = true; state.searchTargetId = null; + state.providerAuth = null; state.readEnvironmentIds = []; state.updateEnvironmentIds = []; state.mutationEnvironmentIds = []; @@ -445,4 +452,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/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index d79ba9d947cb8..d70e264a316a7 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -98,6 +98,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ExpandableText } from "./ExpandableText"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { useSecretSources } from "./SecretSourceInput"; import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderAuthSection } from "./ProviderAuthSection"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; @@ -655,7 +656,7 @@ export function EnvironmentProviderSettings({ void (async () => { const result = await refreshServerProviders({ environmentId, - input: { refreshModels: true }, + input: { refreshModels: true, reloadSecrets: true }, }); refreshingRef.current = false; setIsRefreshingProviders(false); @@ -1292,6 +1293,7 @@ function ProviderInstanceRow({ readonly actions: ProviderInstanceRowActions; }) { const allProjects = useProjects(); + const secretSources = useSecretSources(environmentId); const acpProjects = mode === "editor" && liveProvider?.driver === "acpRegistry" ? allProjects.filter((project) => project.environmentId === environmentId) @@ -1329,6 +1331,7 @@ function ProviderInstanceRow({ return ( = 10 ? 1 : 2)}s`; + if (seconds < 60) return `${seconds.toFixed(seconds >= 10 ? 1 : 2).replace(/\.0+$/, "")}s`; const minutes = seconds / 60; - if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2)}m`; - return `${(minutes / 60).toFixed(2)}h`; + if (minutes < 60) return `${minutes.toFixed(minutes >= 10 ? 1 : 2).replace(/\.0+$/, "")}m`; + return `${(minutes / 60).toFixed(2).replace(/\.0+$/, "")}h`; } function formatDurationMicros(value: number): string { if (value < 1_000) return `${Math.round(value)} ยตs`; - if (value < 1_000_000) return `${(value / 1_000).toFixed(2)} ms`; - return `${(value / 1_000_000).toFixed(2)} s`; + if (value < 1_000_000) return `${(value / 1_000).toFixed(2).replace(/\.0+$/, "")} ms`; + return `${(value / 1_000_000).toFixed(2).replace(/\.0+$/, "")} s`; } function formatSampleInterval(valueMs: number): string { @@ -789,7 +789,7 @@ function AttributionTable({ entries }: { entries: ReadonlyArray {entry.count} - {(entry.durationMs / 1_000).toFixed(2)}s + {(entry.durationMs / 1_000).toFixed(2).replace(/\.0+$/, "")}s ))} 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 28d9b25b21d8b..4416a16001764 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -418,9 +418,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 }) } > @@ -487,6 +496,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..ab5872955def8 --- /dev/null +++ b/apps/web/src/components/settings/SecretProviderDialog.logic.ts @@ -0,0 +1,119 @@ +import { + SecretProviderId, + SecretProviderKind, + SecretProviderLabel, + type SecretProviderConfig, +} from "@t3tools/contracts"; +import { + OnePasswordSecretProviderConfig, + type OnePasswordAccount, +} from "@t3tools/secret-provider-onepassword/shared/definition"; +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/SecretSourceInput.browser.test.tsx b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx new file mode 100644 index 0000000000000..e1b67465c2168 --- /dev/null +++ b/apps/web/src/components/settings/SecretSourceInput.browser.test.tsx @@ -0,0 +1,82 @@ +import { SecretProviderId, SecretProviderKind } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render, type Screen } from "../../../test/render"; +import { SecretSourceInput } from "./SecretSourceInput"; + +const personal = SecretProviderId.make("personal"); +const work = SecretProviderId.make("work"); +const sources = [ + { id: personal, kind: SecretProviderKind.make("one_password"), label: "Personal" }, + { id: work, kind: SecretProviderKind.make("one_password"), label: "Work" }, +]; + +const reference = (screen: Screen) => screen.getByRole("textbox", { name: "API key reference" }); + +async function commitReference(screen: Screen, value: string) { + await userEvent.clear(reference(screen)); + await userEvent.type(reference(screen), value); + await userEvent.keyboard("{Enter}"); +} + +describe("SecretSourceInput", () => { + it("moves a reference to another provider only once one is committed for it", async () => { + const onSecretChange = vi.fn(); + await using screen = await render( + {}} + onSecretChange={onSecretChange} + />, + ); + + await userEvent.click(screen.getByRole("combobox", { name: "API key source" })); + await userEvent.click(screen.getByRole("option", { name: "Work" })); + expect(onSecretChange).not.toHaveBeenCalled(); + await expect.element(reference(screen)).toHaveValue(""); + + await commitReference(screen, "op://Shared/OpenAI/credential"); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith({ + provider: work, + reference: "op://Shared/OpenAI/credential", + }); + }); + + it("removes the reference when its field is cleared", async () => { + const onSecretChange = vi.fn(); + await using screen = await render( + {}} + onSecretChange={onSecretChange} + />, + ); + + await userEvent.clear(reference(screen)); + await userEvent.keyboard("{Enter}"); + expect(onSecretChange).toHaveBeenCalledExactlyOnceWith(undefined); + }); + + it("rejects a reference 1Password cannot read", async () => { + const onSecretChange = vi.fn(); + await using screen = await render( + {}} + onSecretChange={onSecretChange} + />, + ); + + await commitReference(screen, "Private/OpenAI/credential"); + expect(onSecretChange).not.toHaveBeenCalled(); + await expect.element(reference(screen)).toHaveAttribute("aria-invalid", "true"); + await expect.element(reference(screen)).toHaveValue("Private/OpenAI/credential"); + }); +}); diff --git a/apps/web/src/components/settings/SecretSourceInput.tsx b/apps/web/src/components/settings/SecretSourceInput.tsx new file mode 100644 index 0000000000000..092e59b9dcd0f --- /dev/null +++ b/apps/web/src/components/settings/SecretSourceInput.tsx @@ -0,0 +1,185 @@ +import { + SecretReference, + type EnvironmentId, + type SecretProviderConfig, + type SecretProviderId, + type SecretProviderKind, +} from "@t3tools/contracts"; +import { builtInSecretProviderDefinitions } from "@t3tools/secret-provider-definitions/registry"; +import { useAtomValue } from "@effect/atom-react"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { KeyRoundIcon, TypeIcon } from "lucide-react"; +import { useState, type ComponentProps, type ComponentType, type ReactNode } from "react"; + +import { isSecretsFeatureEnabled } from "../../env"; +import { useEnvironmentSettings } from "../../hooks/useSettings"; +import { OnePasswordIcon } from "../Icons"; +import { serverEnvironment } from "../../state/server"; +import { DraftInput } from "../ui/draft-input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SecretInput } from "./SecretInput"; +import { classifySecretProvider } from "./SecretProviderDialog.logic"; + +export interface SecretSource { + readonly id: SecretProviderId; + readonly kind: SecretProviderKind; + readonly label: string; +} + +const NO_SECRET_SOURCES: ReadonlyArray = []; +const STORED_VALUE = "__stored__"; +const decodeSecretReference = Schema.decodeUnknownOption(SecretReference); + +/** Secret providers a setting can point at; empty when the environment cannot read references. */ +export function useSecretSources(environmentId: EnvironmentId): ReadonlyArray { + const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders); + const supported = useAtomValue( + serverEnvironment.configValueAtom(environmentId), + (config) => config?.secretReferences === true, + ); + if (!isSecretsFeatureEnabled || !supported) return NO_SECRET_SOURCES; + return (Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>) + .filter(([, config]) => classifySecretProvider(config).kind !== "unsupported") + .map(([id, config]) => ({ id, kind: config.kind, label: config.label })); +} + +function SourceLabel(props: { + readonly icon: ComponentType<{ readonly className?: string }>; + readonly label: string; +}) { + return ( + + + {props.label} + + ); +} + +type SecretSourceInputProps = Omit, "value" | "children"> & { + readonly value: string | SecretReference; + readonly sources: ReadonlyArray; + readonly onSecretChange: (secret: SecretReference | undefined) => void; + /** Places the parts in the caller's layout; `source` is null when there is nothing to pick. */ + readonly children?: (parts: { + readonly source: ReactNode; + readonly value: ReactNode; + }) => ReactNode; +}; + +/** A secret entered here, or read from a secret provider when one is configured. */ +export function SecretSourceInput({ + value, + sources, + onSecretChange, + children, + ...rest +}: SecretSourceInputProps) { + // A provider picked here applies once a reference for it is committed. + const [pendingProvider, setPendingProvider] = useState(null); + const [rejectedReference, setRejectedReference] = useState(null); + const secret = typeof value === "string" ? undefined : value; + const input = { ...rest, value: typeof value === "string" ? value : "" }; + const provider = pendingProvider ?? secret?.provider ?? null; + const reference = secret !== undefined && secret.provider === provider ? secret.reference : ""; + const providerSource = sources.find((source) => source.id === provider); + const definition = providerSource && builtInSecretProviderDefinitions.find(providerSource.kind); + const label = input["aria-label"] ?? "Secret"; + const providerLabel = (id: SecretProviderId) => + sources.find((source) => source.id === id)?.label ?? id; + const providerIcon = (id: SecretProviderId) => + sources.some((source) => source.id === id) ? OnePasswordIcon : KeyRoundIcon; + + const source = + sources.length === 0 && provider === null ? null : ( + + ); + + const valueField = + provider === null ? ( + + ) : ( +
    + { + if (next.trim().length === 0) { + setRejectedReference(null); + if (pendingProvider === null && secret !== undefined) onSecretChange(undefined); + return; + } + const decoded = decodeSecretReference({ provider, reference: next }); + if ( + Option.isNone(decoded) || + definition?.isReference(decoded.value.reference) === false + ) { + setRejectedReference(next); + return; + } + setRejectedReference(null); + setPendingProvider(null); + onSecretChange(decoded.value); + }} + autoComplete="off" + placeholder={definition?.referencePlaceholder} + spellCheck={false} + aria-label={`${label} reference`} + aria-invalid={rejectedReference !== null} + /> + {rejectedReference !== null ? ( +

    + {definition?.invalidReferenceMessage ?? "Enter a valid secret reference."} +

    + ) : null} +
    + ); + + if (children !== undefined) return children({ source, value: valueField }); + if (source === null) return valueField; + return ( +
    +
    {source}
    + {valueField} +
    + ); +} 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 5e5d124e0469f..9b7d422355bd8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -31,6 +31,7 @@ import { PythonEnvironmentSettings } from "./PythonEnvironmentSettings"; import { QuickMessageSettingsRow } from "./QuickMessageSettings"; import { RepositoryOwnersAddInput, RepositoryOwnersList } from "./RepositoryOwnersEditor"; +import { useAtomValue } from "@effect/atom-react"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { CheckIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -43,7 +44,7 @@ import { type ProviderInstanceId, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; - +import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; import { createModelSelection } from "@t3tools/shared/model"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; @@ -88,7 +89,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"; @@ -119,6 +120,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", @@ -171,39 +179,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; @@ -301,7 +308,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"; @@ -337,23 +345,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={