diff --git a/.agents/skills/test-t3-app/SKILL.md b/.agents/skills/test-t3-app/SKILL.md index 06fe9867b15ae..6fde6fff30984 100644 --- a/.agents/skills/test-t3-app/SKILL.md +++ b/.agents/skills/test-t3-app/SKILL.md @@ -21,6 +21,19 @@ Test with meaningful project and thread data. Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) only when inspecting or seeding SQLite. Stop the test server before direct fixture writes. +## Drive agent turns for free + +Do not spend a paid model to see a turn render. `vp run dev` registers the +`Replay (dev)` provider, which replays recorded turns with simulated streaming +through the real adapter and orchestration. Its models are scenarios: +`showcase` covers thinking, a plan, tool calls, an approval, and markdown. +Press `Ctrl+Alt+Shift+R` for a picker (GNOME takes that key for screen +recording), call `window.t3dev.replay("showcase")` +with `preview_evaluate`, or run +`node apps/server/src/bin.ts thread start --base-dir .t3 --provider devReplay --model showcase "go"`. +When the test needs real provider behavior, use that provider's cheapest +model, such as Haiku for Claude or Luna for Codex. + ## Use the Browser panel Call `preview_status`, then `preview_open` if the Browser panel is diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a1..66be80cd12312 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -31,11 +31,21 @@ when inspecting or seeding SQLite. Stop the test server before fixture writes. From the checkout being tested on the selected device host, run: ```bash -node scripts/mobile-native-client.ts ensure +vp run mobile:install ``` -This reuses a matching native client or builds and installs one. Authorized -mobile verification includes that build step unless the user prohibits it. +This installs the dev client for the checkout's native fingerprint: from the +device, the local cache, or the shared cache in seconds, building once only +when no machine has that fingerprint yet (5-15 minutes, logged to the file it +names). The device id may be omitted when exactly one device is booted. It +prints one JSON line; on failure it names the next command. Authorized mobile +verification includes that step unless the user prohibits it. + +For a device you opened in an agent-device session elsewhere (a device pool, +cloud, or remote host), run `vp run mobile:install --session ` +from the directory you opened the session in. It installs the cached build into +that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also +opens it against Metro) and never builds. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.coderabbit.config.ts b/.coderabbit.config.ts index 06983eaa71fd1..0b7d0182e6df7 100644 --- a/.coderabbit.config.ts +++ b/.coderabbit.config.ts @@ -26,6 +26,11 @@ export default defineConfig((ctx) => ({ path: "apps/web/src/**/*.{tsx,css}", instructions: "Hold changed code to the rules in docs/internals/web-ui.md.", }, + { + path: "**/*.test.*", + instructions: + "Hold changed tests to the rules in docs/internals/testing.md. Request tests only as those rules allow.", + }, ], }, knowledge_base: { @@ -33,6 +38,7 @@ export default defineConfig((ctx) => ({ filePatterns: [ { files: "docs/internals/effect-services.md", applyTo: "{apps,packages,infra}/**/*.ts" }, { files: "docs/internals/web-ui.md", applyTo: "apps/web/src/**/*.{tsx,css}" }, + { files: "docs/internals/testing.md", applyTo: "{apps,packages,infra}/**/*.{ts,tsx}" }, ], }, }, diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index 7a6a1aa122a70..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,18 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: @@ -14,10 +27,11 @@ runs: echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -33,35 +47,107 @@ runs: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies + if: inputs.install == 'true' + shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' shell: bash - run: vp install + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index d7ebb1a0790cd..77a526076de4d 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -1,21 +1,10 @@ -// Decides which CI jobs a change needs. Prints `key=value` lines for -// $GITHUB_OUTPUT; a job runs unless its key is "false". -// -// node ci-changes.cjs --paths gate by the listed paths -// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint -// node ci-changes.cjs changed paths unknown: run everything -// -// scripts/check-changed.ts requires the same decisions so local runs match CI. -// -// Every changed path maps to workspace packages, which then pull in their -// dependents from package.json. A path this file does not recognize runs -// everything, so a new root file or directory can only cost minutes. +// Unknown paths run every job; recognized packages pull in their dependents. +// scripts/check-changed.ts shares these decisions so local checks match CI. const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "../.."); -// Never read by a build, typecheck, or test. Lint still formats them. const INERT = [ /^docs\//, /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, @@ -23,15 +12,13 @@ const INERT = [ /^LICENSE$/, ]; -// Run everything: the root vite.config.ts loads the first as setup for every -// package's tests, and the vendored mobile tarballs are installed dependencies. +// Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, /^apps\/mobile\/deps\//, ]; -// Paths outside a package, or files inside one that other packages read -// through relative imports rather than a package.json dependency. +// Includes cross-package reads absent from package.json dependencies. const EXTRA_OWNERS = [ // Vite configs and tsconfigs of these apps import scripts/lib directly. [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], @@ -43,10 +30,10 @@ const EXTRA_OWNERS = [ ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; -// Tests that read another package's files without declaring it. The scripts -// tests read app manifests, assets, and sources (cursor-sdk-packaging, -// third-party-licenses, brand-assets, build-desktop-artifact). +// These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { + // Evals import the server's source to test its real model-facing text. + "@t3tools/evals": ["t3"], "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ "@t3tools/desktop", @@ -56,17 +43,12 @@ const TEST_ONLY_DEPS = { "@t3tools/provider-cursor", ], }; -// Declared dependencies that only feed a build step: the server bundles the -// built web app (apps/server/vite.config.ts) but never imports it. +// The server bundles the web build without importing it. const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; -// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule -// configuration that decides how it lints them, plus the root package.json -// that defines the lint:mobile command. const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; -// Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -108,8 +90,7 @@ function readWorkspace() { return packages; } -// Returns the packages a path touches, [] for an inert path, or null when the -// path is not understood and everything must run. +// [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { if (INERT.some((pattern) => pattern.test(file))) return []; // A manifest can change dependencies or versions other packages read. @@ -148,7 +129,6 @@ function decide(files, packages, full) { if (everything) { affected = all; } else { - // Add dependents until nothing changes. let grew = true; while (grew) { grew = false; diff --git a/.github/scripts/fork-release-health.cjs b/.github/scripts/fork-release-health.cjs index 0615c3a3ff15f..00aaed11972d3 100644 --- a/.github/scripts/fork-release-health.cjs +++ b/.github/scripts/fork-release-health.cjs @@ -17,16 +17,8 @@ const FAILED_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure"]); const hours = (ms) => `${Math.round(ms / (60 * 60 * 1000))} h`; /** - * @param {{ - * mainHead: { sha: string, committedAt: string }, - * nightlies: Array<{ tagName: string, publishedAt: string }>, - * workflows: Array<{ - * name: string, - * cancelledIsFailure: boolean, - * runs: Array<{ conclusion: string, createdAt: string, url: string }>, - * }>, - * }} input runs are newest first. - * @returns {string[]} one message per problem; empty when healthy. + * @param {{mainHead: {sha: string, committedAt: string}, nightlies: Array<{tagName: string, publishedAt: string}>, workflows: Array<{name: string, cancelledIsFailure: boolean, runs: Array<{conclusion: string, createdAt: string, url: string}>}>}} input Runs are newest first. + * @returns {string[]} One message per problem; empty when healthy. */ function evaluateReleaseHealth({ mainHead, nightlies, workflows }) { const problems = []; diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 90b368b2aa036..d017fc907f124 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,7 +1,4 @@ -// Publishes closed-testing betas, or downloads the internal APK for a normal -// nightly release. Play generates and signs the APK from the -// uploaded bundle with the app signing key, so it carries the same signature -// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml. +// Play signs generated APKs with the app signing key, matching Play Store installs. const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); @@ -50,10 +47,7 @@ async function play(token, method, url) { return response; } -// Highest version code the track serves, with its release name. Play names a -// release after the bundle's versionName unless the uploader sets one. This -// endpoint reads without opening an edit; an edit would invalidate any open -// EAS Submit edit made with the same service account. +// Opening an edit would invalidate any EAS Submit edit using the same service account. async function newestTrackBuild(token, appUrl, trackName) { const { releases = [] } = await ( await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`) @@ -82,8 +76,7 @@ function readRelease(tag) { } } -// Download to a temporary directory so a failed request cannot leave a partial -// APK among the release assets. +// Stage downloads so failed requests cannot leave partial release assets. async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py index 0b3a53285916b..1730ee5c8d752 100644 --- a/.github/scripts/stage-preview-bundle.py +++ b/.github/scripts/stage-preview-bundle.py @@ -12,8 +12,7 @@ "server/dist/client/index.html", "desktop/dist-electron/main.cjs", } -# The current bundle is about 32 MiB compressed. Bound extraction on the -# trusted runner even when the PR replaces the uploader entirely. +# Bound trusted-runner extraction even when a PR replaces the uploader. MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 MAX_ENTRIES = 50_000 @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path): files.add(name) if not REQUIRED_FILES <= files: raise ValueError("Preview bundle is missing required entry points") - # Validate all names before writing anything. This is a fresh directory - # outside the checkout; neither pre-existing links nor trusted files - # can be followed or overwritten. ZIP permissions are never restored. + # Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten. destination.mkdir(parents=True, exist_ok=False) for entry in entries: target = destination / entry.filename diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 5ed6a6ff498a9..ca57769ed490c 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -294,8 +294,7 @@ async function resolve({ github, context, core }) { const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); const sourceArtifact = findResultArtifact(sourceArtifacts); - // CI requires the artifact whenever the server tests run, so a passing run - // without one skipped them because the PR cannot affect the server. + // Successful server tests require this artifact; its absence means CI skipped them. if (!sourceArtifact && source.conclusion === "success") { core.info("CI skipped the server tests for this change; nothing to report."); core.setOutput("publish", "false"); diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 13ca38fed4ad6..78a0156d1eaa1 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -11,8 +11,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml new file mode 100644 index 0000000000000..9396d8e3e7a63 --- /dev/null +++ b/.github/workflows/agent-context-budget.yml @@ -0,0 +1,55 @@ +name: Agent Context Budget + +# Fails when a PR raises a value in agentContextBudget.json above main's. AgentContextBudget.test.ts +# keeps that file equal to the measured context, so the context cannot grow by editing the file. +# To accept growth on purpose, a maintainer adds the agent-context-increase label. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: agent-context-budget-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + budget: + name: Agent context budget + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + BUDGET: apps/server/src/provider/agentContextBudget.json + ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: apps/server/src/provider/agentContextBudget.json + sparse-checkout-cone-mode: false + + - name: Compare with main + run: | + git fetch --quiet --depth=1 origin main + if ! git cat-file -e "FETCH_HEAD:$BUDGET" 2>/dev/null; then + echo "main has no agent context budget yet." + exit 0 + fi + git show "FETCH_HEAD:$BUDGET" > "$RUNNER_TEMP/main.json" + grown=$(jq -r --slurpfile main "$RUNNER_TEMP/main.json" ' + to_entries[] + | select($main[0][.key] != null and .value > $main[0][.key]) + | "\(.key): \($main[0][.key]) -> \(.value) (+\(.value - $main[0][.key]))" + ' "$BUDGET") + if [ -z "$grown" ]; then + echo "Agent context is not larger than main." + exit 0 + fi + printf 'Agent context grew compared to main:\n%s\n' "$grown" + if [ "$ALLOW_INCREASE" = "true" ]; then + echo "Accepted by the agent-context-increase label." + exit 0 + fi + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16da9d63a3791..8b2b14211b1be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: ${{ github.repository == 'jayleaton/t3code' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: ${{ github.repository == 'jayleaton/t3code' && 20 || 10 }} @@ -90,6 +93,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -129,6 +134,9 @@ jobs: run: vp check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -160,6 +168,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -218,6 +228,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -259,6 +272,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -300,6 +315,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -317,6 +335,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -346,6 +366,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of # separate workers, so no two server test files ever share a machine and the @@ -355,9 +381,12 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} runs-on: ${{ github.repository == 'jayleaton/t3code' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }} - timeout-minutes: 10 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -381,6 +410,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -417,7 +448,18 @@ jobs: env: T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json - run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} + run: >- + vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }} + --uncached apps/server/integration/transferBudgetV2.integration.test.ts + --verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server + + - name: Upload test cache measurements + if: always() && steps.changes.outputs.test_server != 'false' + uses: actions/upload-artifact@v7 + with: + name: test-cache-${{ matrix.shard }} + path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json + if-no-files-found: error # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a @@ -517,6 +559,9 @@ jobs: run: vp run lint:mobile release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -534,6 +579,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index bf8591f81da9b..5db7a86eb98c9 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -5,12 +5,11 @@ on: branches: - main # Redeploys main after a variable or secret change. Alchemy redeploys the - # Worker when a value it reads changes, so force is rarely needed, and it - # replaces the Postgres runtime role (alchemy-run/alchemy#1832). + # Worker when a value it reads changes, so force is rarely needed. workflow_dispatch: inputs: force: - description: Redeploy every resource, including ones with no detected changes. Replaces the Postgres runtime role. + description: Redeploy every resource, including ones with no detected changes. type: boolean default: false @@ -81,6 +80,7 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} - name: Publish relay deploy commit status uses: actions/github-script@v8 diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index b6581e0e8ea49..4dd434a956e25 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -275,6 +275,7 @@ jobs: --environment production \ --platform ${{ inputs.platform }} \ --message "$OTA_MESSAGE" \ + --emit-metadata \ --non-interactive # Reconcile each version separately because the major is in the fingerprint. @@ -313,8 +314,11 @@ jobs: --environment production \ --platform "$platform" \ --message "$message" \ + --emit-metadata \ --non-interactive; then echo ":white_check_mark: $platform/$profile: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" + # The next target's export replaces dist; keep this one for the map upload. + rm -rf "dist-$platform-$profile" && mv dist "dist-$platform-$profile" else failed=1 echo ":x: $platform/$profile: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" @@ -354,3 +358,18 @@ jobs: fi done exit "$failed" + + # Lets the relay symbolicate crash stacks from the updates published + # above. A step of its own so the R2 credentials reach only this script, + # not `eas update` and the dependencies it runs. Never fails the job. + - name: Upload OTA source maps + if: ${{ !cancelled() && steps.expo-token.outputs.present == 'true' }} + working-directory: apps/mobile + env: + MOBILE_SOURCEMAPS_R2: ${{ secrets.MOBILE_SOURCEMAPS_R2 }} + run: | + for dir in dist dist-*; do + if [ -f "$dir/eas-update-metadata.json" ]; then + node scripts/upload-sourcemaps.mts ota "$dir" + fi + done diff --git a/.github/workflows/relay-preview.yml b/.github/workflows/relay-preview.yml index 53a71110cef1f..fdce22c68a59e 100644 --- a/.github/workflows/relay-preview.yml +++ b/.github/workflows/relay-preview.yml @@ -79,3 +79,5 @@ jobs: PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + # Unset by default, so previews keep the T3 Chat link off. + T3CHAT_LINK_SECRET: ${{ secrets.T3CHAT_LINK_SECRET }} diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9f27a70211b27..7893437c83bec 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -80,6 +80,12 @@ on: required: false default: false type: boolean + # Whether the job opens the packaged app against a seeded database and + # opens a thread before uploading it. Linux AppImage or macOS DMG. + launch_check: + required: false + default: false + type: boolean # A runner of the target's own platform and architecture, for a `runner` # that cannot execute the target. The build job skips everything that # runs the packaged binaries, and a second job runs it there. @@ -174,13 +180,6 @@ jobs: echo "T3CODE_DESKTOP_UPDATE_REPOSITORY=$UPDATE_REPOSITORY" >> "$GITHUB_ENV" fi - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -190,10 +189,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Verify macOS shared encryption and legacy Agents credentials if: inputs.platform == 'mac' && inputs.brand == 'agents' @@ -302,6 +303,63 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + - name: Resolve target Node.js for the CLI executable + id: target_node + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" + + # The download only changes with engines.node, so a cached copy keeps + # releases off nodejs.org, which stalls on the runners some days. + - name: Restore target Node.js for the CLI executable + id: target_node_cache + if: inputs.cli_archive && inputs.platform == 'win' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} + + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. A transfer + # under 100 KB/s for 30s counts as failed, so a stall retries. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" + fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) + "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" + "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + + # Saved right away so a later failure in this job still keeps the download. + - name: Save target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: ${{ steps.target_node_cache.outputs.cache-primary-key }} + + - name: Install target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" + mkdir -p "$dest" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. @@ -585,6 +643,38 @@ jobs: vp run dist:desktop:artifact "${args[@]}" + # A build can package fine and still not open (a missing module in the + # backend or the bundle). This launches the app from the shipped + # AppImage or DMG. + - name: Launch check + if: inputs.launch_check + shell: bash + run: | + set -euo pipefail + check=(node scripts/smoke-desktop-app.ts --artifacts-dir "$RUNNER_TEMP/desktop-launch-check") + if [[ "${{ inputs.platform }}" == "mac" ]]; then + mount="$RUNNER_TEMP/desktop-launch-dmg" + hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$(ls "$GITHUB_WORKSPACE"/release/*.dmg)" + trap 'hdiutil detach "$mount" -force >/dev/null || true' EXIT + app=$(ls -d "$mount"/*.app) + executable=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$app/Contents/Info.plist") + "${check[@]}" --executable "$app/Contents/MacOS/$executable" + else + sudo apt-get install -y xvfb + appimage=$(ls "$GITHUB_WORKSPACE"/release/*.AppImage) + chmod +x "$appimage" + (cd "$RUNNER_TEMP" && "$appimage" --appimage-extract >/dev/null) + xvfb-run -a "${check[@]}" --executable "$RUNNER_TEMP/squashfs-root/t3code" + fi + + - name: Upload launch check output + if: failure() && inputs.launch_check + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-launch-check-${{ inputs.platform }}-${{ inputs.arch }} + path: ${{ runner.temp }}/desktop-launch-check + if-no-files-found: ignore + # The verify job runs what this host could not: the packaged natives and # the CLI. Its scripts are bundled so that job needs no install. - name: Stage verification inputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 867083b3fdd35..e2db339f084d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -336,6 +344,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test + # Browser tests run source modules; this boots the production chunk graph. + - name: Boot the built web client + run: | + vp run --filter @t3tools/web build + node apps/web/scripts/verify-built-boot.ts + # Server test files run one at a time, so more small runners beat fewer big ones. test_server: name: Release server tests ${{ matrix.shard }} @@ -359,6 +373,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -404,14 +420,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -506,13 +518,6 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -522,10 +527,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -611,6 +618,7 @@ jobs: rust_target: aarch64-apple-darwin resource_key: darwin-arm64 cli_archive: true + launch_check: true desktop_mac_x64: name: Desktop macOS x64 @@ -660,6 +668,7 @@ jobs: rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 cli_archive: true + launch_check: true # node-pty has no Linux prebuild and compiles from source, so the arm64 app # and archive are built on arm64 hardware rather than cross-built. @@ -771,14 +780,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -967,15 +972,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1031,15 +1031,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1135,11 +1130,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1206,14 +1199,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1277,11 +1266,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1336,15 +1323,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1401,14 +1383,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 3def8478bbdc2..4b3f8952d503d 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/.gitignore b/.gitignore index a888e146c1ddc..31108d191715d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ apps/mobile/.generated/ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ +artifacts/mobile-e2e/ +artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index f7cef3929ab10..f005f899595f5 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/test-signal.md b/.macroscope/check-run-agents/test-signal.md new file mode 100644 index 0000000000000..5c5319aafb739 --- /dev/null +++ b/.macroscope/check-run-agents/test-signal.md @@ -0,0 +1,27 @@ +--- +title: Test Signal +model: gpt-6-1-terra +reasoning: low +input: incremental +tools: + - browse_code + - modify_pr +# An explicit include overrides ignore.md, which skips test files for every other check. +include: + - "**/*.test.*" +exclude: + - ".repos/**" +requires: + - Check +maxBudgetPerRun: 1 +maxBudgetPerPR: 5 +conclusion: neutral +--- + +# Test signal review + +Review only the tests this pull request adds or grows. Tests it moves, renames, or deletes are not findings. The rules are in `docs/internals/testing.md` under `## What to test`; read that section first and hold new tests to it. + +Post each finding as a brief inline comment on the new test with one fix. Ask only for fewer or smaller tests, never for more coverage. + +When there are no findings, make the entire final response exactly `All clear` on one line with nothing else. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index ab37120ff344f..047dafbdd4be1 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-6-sol -effort: max +model: gpt-6-1-sol +reasoning: low input: incremental tools: - browse_code diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index e79d0e71dc863..a7b10f64404a6 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -1,8 +1,5 @@ -// Windows WSL dev mode runs the server under Linux from the same node_modules -// as the Windows client, so Windows installs also need Linux x64 glibc native -// modules. Other platforms install only their own binaries; setting this -// everywhere added ~2.2 GB of foreign binaries to every macOS install. -// Release scripts stage their own installs with explicit architectures. +// Windows installs need Linux x64 glibc binaries because WSL shares node_modules. +// Keep other platforms native; release scripts set their own architectures. module.exports = { hooks: { // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 0000000000000..0e62827f9d64e --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/AGENTS.md b/AGENTS.md index a72e0894cd1bc..e8eb6fd49b883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,8 +90,8 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. -- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only copy of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It reads the source in short transactions, because one long read keeps the live server from checkpointing. Copy a large live database any other way only in short reads too, never in one long transaction. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. +- The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. +- Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. - Copy in, never symlink. Data flows one way: into your sandbox, never back out. @@ -102,10 +102,13 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - While you iterate, run `vp test run `. For big files, add `-t ""`. - Use `vp run typecheck`, not `npx tsc`; it caches per package. Both run tsc-rs (`typescript` is the `@tsc-rs/typescript` drop-in). - Run `node -e` snippets from `apps/server` or the owning package. `effect` does not resolve from the repo root. -- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation. +- Test behavior that can break, such as orchestration and event-sourcing, provider protocol translation, auth and permission guards, persistence and migrations, external-format parsing, race fixes, and the logic a bug fix changed. Leave out tests for what types already guarantee, trivial logic, copy text, smoke runs, mock-call mirrors, callback wiring, and props rendered to static markup. +- Extend before you add. When an existing test or table covers the behavior, add a row or an assertion there. Write a new `it` only when no case fits. +- Test at one layer: the lowest one that fails on `main`. Do not repeat the case in a wrapper test, at each call site, or in web and mobile copies of the same logic. +- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in the module's existing test file), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. @@ -134,12 +137,18 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. -For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. +- Real agent turns cost money. To watch a turn stream through the pipeline and UI, use the free `Replay (dev)` provider that `vp run dev` registers; [test-t3-app](.agents/skills/test-t3-app/SKILL.md) shows how to start one. Use a real provider only when the change depends on that provider's behavior, and then pick its cheapest model, such as Haiku for Claude or Luna for Codex. + +For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. + +## Evals + +`evals/` checks how real models behave with T3 Code's model-facing text: agent instructions, MCP tool descriptions and schemas, and generated titles. Before you change that text, read [evals/README.md](evals/README.md) and run the evals it names. When you fix a model behavior bug, add a case that fails before the fix. ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): use `cafe` for existing PRs, issues, comments and labels, but push and open PRs on GitHub, where CI runs. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. @@ -153,7 +162,7 @@ Most code changes do not need an internal documentation change. Agents can read - `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. - Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. -- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- A local reason the code can't show goes in a short nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. - When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. - `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. - Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. @@ -193,7 +202,8 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. -- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. +- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/no-unreasoned-state-sync` lint rule reports every effect; one that stays states its reason with `// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- `. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/README.md b/README.md index 95a7c0341c578..4481482edb840 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ We are (mostly) not accepting contributions yet. Small fixes may be considered. Full docs live in [docs/](./docs). There's no docs site yet. +- [FAQ](./docs/user/faq.md) - [Install and first run](./docs/user/install.md) - [Permission modes](./docs/user/permission-modes.md) - [Keyboard shortcuts](./docs/user/keybindings.md) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cf545dafbf5ff..95fb3cd88fd58 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,14 +1,13 @@ { "name": "@t3tools/desktop", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", - "test": "vp test run --passWithNoTests", - "smoke-test": "node scripts/smoke-test.mjs" + "test": "vp test run --passWithNoTests" }, "dependencies": { "@clerk/electron": "catalog:", diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 0fb4377f07be0..e433dc498cada 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -372,9 +372,7 @@ function buildMacLauncher(electronBinaryPath) { JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { if (isDevelopment) { - // The launcher also handles protocol activations outside the dev runner, - // so refresh its fallback environment on every launch. Never let a value - // captured by an older parent app override the live dev-runner environment. + // Protocol activations bypass the dev runner, so refresh the fallback environment on every launch. writeDevelopmentEnvironmentScript(); if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) { signMacLauncherBundle(targetAppBundlePath); @@ -385,10 +383,7 @@ function buildMacLauncher(electronBinaryPath) { } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); - // verbatimSymlinks keeps the framework's relative symlinks intact - // (e.g. Resources -> Versions/Current/Resources). Without it cpSync - // rewrites them to absolute paths into node_modules, which escape the - // bundle and crash sandboxed helper processes (icudtl.dat not found). + // Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers. NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true, @@ -400,10 +395,7 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Launching the node_modules copy makes macOS associate the process (and Dock label) with - // Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional - // executable name also keeps Electron's default-app runtime in development mode instead of - // making app.isPackaged report true. + // Electron.app's executable name keeps default-app mode active without making app.isPackaged true. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs deleted file mode 100644 index 05195bb8d8cda..0000000000000 --- a/apps/desktop/scripts/smoke-test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import * as NodeChildProcess from "node:child_process"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; -import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; - -const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); -const desktopDir = NodePath.resolve(__dirname, ".."); -const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs"); - -console.log("\nLaunching Electron smoke test..."); - -const electronCommand = resolveElectronLaunchCommand([mainJs]); -const child = NodeChildProcess.spawn(electronCommand.electronPath, electronCommand.args, { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - VITE_DEV_SERVER_URL: "", - ELECTRON_ENABLE_LOGGING: "1", - }, -}); - -let output = ""; -child.stdout.on("data", (chunk) => { - output += chunk.toString(); -}); -child.stderr.on("data", (chunk) => { - output += chunk.toString(); -}); - -const timeout = setTimeout(() => { - child.kill(); -}, 8_000); - -child.on("exit", () => { - clearTimeout(timeout); - - const fatalPatterns = [ - "Cannot find module", - "MODULE_NOT_FOUND", - "Refused to execute", - "Uncaught Error", - "Uncaught TypeError", - "Uncaught ReferenceError", - ]; - const failures = fatalPatterns.filter((pattern) => output.includes(pattern)); - - if (failures.length > 0) { - console.error("\nDesktop smoke test failed:"); - for (const failure of failures) { - console.error(` - ${failure}`); - } - console.error("\nFull output:\n" + output); - process.exit(1); - } - - console.log("Desktop smoke test passed."); - process.exit(0); -}); diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 22a5bdfc40a88..66980f6cb73ee 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -31,6 +31,7 @@ import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; +import * as DesktopStartupRecovery from "./DesktopStartupRecovery.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; @@ -330,6 +331,7 @@ const startup = Effect.gen(function* () { yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; + yield* DesktopStartupRecovery.listen; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); @@ -344,11 +346,7 @@ const scopedProgram = Effect.scoped( const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* Effect.addFinalizer(() => - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. + // Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown. stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 69895f44a489f..abad59633aca5 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import { pipe } from "effect/Function"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; @@ -151,8 +152,10 @@ export async function startDesktopAppControlServer(input: { } if (!isDesktopAppActivationRequest(parsed)) { - finish( - invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + pipe( + requestIdFromUnknown(parsed), + (requestId) => invalidResponse(requestId, "The desktop app request is invalid."), + finish, ); return; } @@ -186,10 +189,7 @@ export async function startDesktopAppControlServer(input: { server.listen(address); }); - // Closing a Unix socket server unlinks the path it was bound to, even when - // another app's socket lives there now. Bind a staging path and move it onto - // the address instead, so a later close only unlinks the staging path, which - // is already gone. + // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first. const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); @@ -213,7 +213,6 @@ export async function startDesktopAppControlServer(input: { let server: NodeNet.Server; let inode: number | null = null; if (input.directory === null) { - // Named pipes close with the app that owns them, so no other app can remove this one. server = await listen(input.address); } else { await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); @@ -222,10 +221,10 @@ export async function startDesktopAppControlServer(input: { let closed = false; const reclaimOnce = async () => { - // Never replace a socket that exists, so two apps cannot trade the path back and forth. + // Replacing an existing socket would let two apps trade the path indefinitely. if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { - // A watch follows the directory's inode, so a recreated directory needs a new one. + // Directory watches follow inodes, so recreate the watch after directory replacement. watchDirectory(input.directory); } const next = await bindUnix(input.directory, "claim-free").catch( @@ -269,7 +268,7 @@ export async function startDesktopAppControlServer(input: { close: async () => { if (closed) return; closed = true; - // A running reclaim can replace the watcher, so close the watcher after it. + // Reclaim can replace the watcher; close it afterward. await pendingReclaim; watcher?.close(); for (const socket of sockets) socket.destroy(); @@ -386,7 +385,10 @@ export const make = Effect.gen(function* () { webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); }); }), - complete: (response) => Effect.sync(() => broker.complete(response)), + // The IPC handler owns the activation-completion span. + complete: Effect.fnUntraced(function* (response) { + return yield* Effect.sync(() => broker.complete(response)); + }), }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64c..5fd6983c66682 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -38,7 +38,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -91,9 +91,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - // Electron removes spaces from this name to build the native User-Agent - // product token, but leaves parentheses intact. Keep the runtime name valid - // without rewriting preview sessions (which breaks Turnstile, #7110). + // Electron strips spaces but keeps parentheses in its User-Agent token, breaking Turnstile in preview sessions. yield* electronApp.setName( `${environment.branding.baseName} ${environment.branding.stageLabel}`, ); @@ -107,9 +105,7 @@ export const make = Effect.gen(function* () { yield* electronApp.setAppUserModelId(environment.appUserModelId); } - // Unpackaged runs only. A packaged bundle already carries its icon in - // Info.plist, so setting the dock tile again changes nothing except to - // overwrite a custom icon the user attached to the app themselves. + // Packaged bundles carry their icon in Info.plist; setting it again overwrites the user's custom icon. if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1e572412d4693..34b7286c335ed 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -28,8 +28,6 @@ vi.mock("@clerk/electron", () => ({ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); - -import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -158,6 +156,7 @@ describe("DesktopClerk", () => { `setPath:userData:${userData}`, "requestSingleInstanceLock", "on:second-instance", + "on:open-url", "createClerkBridge", ]); }, @@ -202,7 +201,7 @@ describe("DesktopClerk", () => { }, ); - it.effect("delivers queued startup links and later links once in arrival order", () => { + it.effect("delivers each queued startup link once, before later links", () => { storageMock.mockReturnValue(storageAdapter); createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); const listeners = new Map void>(); @@ -210,6 +209,7 @@ describe("DesktopClerk", () => { const first = "t3code://new?prompt=first"; const second = "t3code://threads/env-1/thread-1"; const later = "t3code://new?prompt=later"; + const launchPair = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; const delivered = Promise.withResolvers(); const electronApp = { on: (name: string, listener: (...args: unknown[]) => void) => @@ -222,13 +222,14 @@ describe("DesktopClerk", () => { const clerk = yield* DesktopClerk.DesktopClerk; listeners.get("second-instance")?.({}, ["t3", first]); listeners.get("second-instance")?.({}, ["t3", second]); + listeners.get("open-url")?.({ preventDefault: () => undefined }, launchPair); assert.deepEqual(received, []); yield* clerk.configure; listeners.get("second-instance")!({}, ["t3", later]); yield* Effect.promise(() => delivered.promise); - assert.deepEqual(received, [first, second, later]); + assert.deepEqual(received, [launchPair, first, second, later]); }).pipe( Effect.scoped, Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), @@ -297,7 +298,7 @@ it.effect( }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: () => Effect.sync(() => revealed.resolve()), } as unknown as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { @@ -427,6 +428,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), } as unknown as ElectronApp.ElectronApp["Service"]; const received: Array = []; + const pairLink = "t3code://pair?pairingUrl=https%3A%2F%2Fvps.example.com%2F%23token%3Dabc"; yield* Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; yield* clerk.configure; @@ -439,6 +441,8 @@ it.effect("hands web and app links to the renderer and leaves other links alone" assert.strictEqual(open("http://localhost:3000/"), 1); assert.strictEqual(open("t3code://new?prompt=hi"), 1); assert.strictEqual(open("t3code://threads/env-1/thread-1"), 1); + assert.strictEqual(open(pairLink), 1); + assert.strictEqual(open("t3code://pair"), 0); assert.strictEqual(open("mailto:hello@example.com"), 0); assert.strictEqual(open("t3code://somewhere-else"), 0); yield* Effect.yieldNow; @@ -447,6 +451,7 @@ it.effect("hands web and app links to the renderer and leaves other links alone" "http://localhost:3000/", "t3code://new?prompt=hi", "t3code://threads/env-1/thread-1", + pairLink, ]); }).pipe( Effect.provide(layerDesktopClerk(true, [], "darwin", undefined, undefined, listeners)), diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 5d8cd99528da3..a1c1defc72c54 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -18,6 +18,7 @@ import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCal import * as ElectronShell from "../electron/ElectronShell.ts"; import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; import { promptLinkAppPath } from "@t3tools/shared/promptLinks"; +import { readPairingAppLink } from "@t3tools/shared/remote"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -120,7 +121,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; @@ -130,14 +131,12 @@ export const make = Effect.gen(function* () { const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - // T3 owns the profile-scoped lock on every platform; Clerk skips it on macOS. - // Quit is asynchronous, so interrupt before the application runtime is built. + // Clerk skips the profile lock on macOS. Interrupt before runtime construction because quit is asynchronous. if (!(yield* electronApp.requestSingleInstanceLock)) { yield* electronApp.quit; return yield* Effect.interrupt; } - // Retain launch arguments while asynchronous startup builds the application runtime. const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { pendingSecondInstances.push(argv); @@ -145,6 +144,15 @@ export const make = Effect.gen(function* () { yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { handleSecondInstance(argv); }); + // macOS hands a launch link to open-url, which can fire before configure runs. + const pendingOpenUrls: Array = []; + let handleOpenUrl = (url: string) => { + pendingOpenUrls.push(url); + return true; + }; + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (handleOpenUrl(url)) event.preventDefault(); + }); yield* Effect.acquireRelease( Effect.try({ @@ -221,7 +229,8 @@ export const make = Effect.gen(function* () { return true; }; const openAppLink = (value: string | undefined) => { - if (!value || promptLinkAppPath(value) === null) return false; + if (!value || (promptLinkAppPath(value) === null && readPairingAppLink(value) === null)) + return false; void runPromise(webLinks.receive(value)); return true; }; @@ -232,15 +241,12 @@ export const make = Effect.gen(function* () { void runPromise(webLinks.receive(url)); return true; }; - yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { - if ( - startProviderAuthHandoff(url) || - resumeProviderAuth(url) || - openWebLink(url) || - openAppLink(url) - ) - event.preventDefault(); - }); + handleOpenUrl = (url) => + startProviderAuthHandoff(url) || + resumeProviderAuth(url) || + openWebLink(url) || + openAppLink(url); + for (const url of pendingOpenUrls.splice(0)) handleOpenUrl(url); yield* electronApp.on("open-file", (event: { preventDefault: () => void }, path: string) => { if (!DesktopWebLinks.isWebPageFile(path)) return; event.preventDefault(); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index c9a79a81d3b55..3cdfdb6cfa383 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -83,7 +83,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -// The machine's own PATH may already hold a t3; each test sets the PATH it means. const machinePath = process.env.PATH; beforeEach(() => { process.env.PATH = ""; @@ -101,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 294f8a8b984e7..2ae070d638263 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -28,14 +28,8 @@ const pathEntries = (value: string | undefined, separator: string) => const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); -/** - * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping - * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for - * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` - * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it - * opens see the change. A failed read exits nonzero rather than reading as - * empty, so a write never replaces the user's PATH wholesale. - */ +// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE. +// Failed reads must exit nonzero to prevent replacing PATH wholesale. const WINDOWS_USER_PATH_SCRIPT = ` $ErrorActionPreference = 'Stop' if ($env:T3_SET -eq '1') { @@ -64,7 +58,7 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; @@ -156,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; 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, ...shadow }; + 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, ...shadow }; - }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e043b4d16d8de..50fcb08d9c64a 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -81,7 +81,6 @@ describe("renderCliShim", () => { executable: NodePath.join(root, "app"), entry: "/bin.mjs", }); - // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index a9d9f04c4e829..3d4b94abff462 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,8 +6,7 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// An AppImage mounts somewhere new each run, so its launcher mounts the -// AppImage itself instead of pointing into it. +// AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; @@ -26,12 +25,7 @@ export type CliShimTarget = | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; -/** - * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which - * reads the entry from inside the asar archive. The launcher's own path and the - * app's T3 home are written in, so the command the server shows is absolute and - * `sudo`, which clears the environment, still runs against this install's home. - */ +/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */ export const renderCliShim = (input: { readonly target: CliShimTarget; readonly shimPath: string; @@ -48,9 +42,7 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // Only a non-ASCII path needs the console switched to UTF-8 to read it; a - // Ctrl-C that ends the batch skips the restore, so the switch is left out - // for the common all-ASCII install. + // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -97,8 +89,7 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The AppImage runtime prints its mount point, then stays mounted until - // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. "work=$(mktemp -d) || exit 1", '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 0b446f8ada300..8b4fc23a99335 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -100,7 +100,7 @@ const withStore = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopConnectionCatalogStore", () => { - it.effect("persists, reads, and clears an encrypted connection catalog", () => + it.effect("persists and reads an encrypted connection catalog", () => withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; @@ -108,9 +108,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.isTrue(yield* store.set(catalog)); assert.deepStrictEqual(yield* store.get, Option.some(catalog)); - - yield* store.clear; - assert.deepStrictEqual(yield* store.get, Option.none()); }), ), ); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index a9020c36d1c89..ceb3f293550bf 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -163,7 +163,6 @@ export class DesktopConnectionCatalogStore extends Context.Service< boolean, DesktopConnectionCatalogStoreWriteError | DesktopConnectionCatalogStoreProtectionError >; - readonly clear: Effect.Effect; } >()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} @@ -503,15 +502,6 @@ export const make = Effect.gen(function* () { yield* writeCatalog(catalog); return true; }), - clear: fileSystem.remove(catalogPath, { force: true }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the desktop connection catalog.", { - catalogPath, - error, - }), - ), - Effect.withSpan("desktop.connectionCatalogStore.clear"), - ), }); }); diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index e4c030f7b8010..a4c793ffd97cc 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -101,8 +101,7 @@ const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; const featureListNames = (value: string | null): ReadonlyArray => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); -// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only -// trust the session's desktop names. +// HYPRLAND_INSTANCE_SIGNATURE can be stale; trust only the session's desktop names. const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean => (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "") .split(":") @@ -118,12 +117,8 @@ const HyprctlVersionJson = fromLenientJson( ); const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson); -// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising -// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium -// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499). -// Only stock builds of these releases count. Matching the commit as well as the tag -// keeps later development builds that still report an old VERSION and tag (Nix -// synthesizes `v${VERSION}`) on the default. +// Hyprland 0.48.0 rejects advertised set_primaries (hyprwm/Hyprland#9444); 0.51.0 accepts it (hyprwm/Hyprland#11499). +// Check commits because dev and Nix builds can retain release versions and tags. const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap = new Map([ ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"], ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"], diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 122aa3393f9cd..90043d9398a2c 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -10,10 +10,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -/** - * The V1 profile is only read, never opened by Chromium, so this works while V1 - * is still running. - */ +/** Reads the V1 profile without opening it in Chromium, even while V1 runs. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, { diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 8c4cf8f94962b..7f75e47ee8b06 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -213,7 +213,7 @@ const isSupervisedRelaunch = Effect.gen(function* () { return (yield* DesktopConfig.DesktopConfig).supervisedRelaunch; }).pipe(Effect.orElseSucceed(() => false)); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 188ce389edff3..e2e2694082be2 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -470,9 +470,7 @@ const themeIconFixture = (source: Uint8Array) => const hicolorDir = path.join(root, "icons", "hicolor"); const appsDir = path.join(hicolorDir, "256x256", "apps"); const cachePath = path.join(hicolorDir, "icon-theme.cache"); - // A fixed time in the past, in whole seconds as fs.utimes expects. const pastSeconds = 1_600_000_000; - // An existing cache that predates any installed icon. const seedCache = Effect.gen(function* () { yield* fileSystem.makeDirectory(appsDir, { recursive: true }); yield* fileSystem.writeFile(cachePath, new Uint8Array([0])); @@ -560,7 +558,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { commands: [], onIconCacheRefresh: fixture.rewriteCache, }; - // A cache stamped later than the new icons, as after a clock correction. const future = fixture.pastSeconds + 10 * 365 * 24 * 60 * 60; yield* fileSystem.utimes(fixture.cachePath, future, future); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 3b56057339f23..06029d839a984 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -13,12 +13,7 @@ import * as NodeURL from "node:url"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// Linux ships as an AppImage, so the .desktop entry users end up with is -// created by whatever integration tool they use (AppImageLauncher names it -// appimagekit_-….desktop) and its filename is not under our control. -// Electron's app.setAsDefaultProtocolClient resolves the desktop id from -// setDesktopName, which cannot match those files — so the browser keeps -// prompting "Choose an application" for every OAuth callback. +// AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -63,9 +58,7 @@ const escapeDesktopEntryString = (value: string): string => .replaceAll("\r", "\\r") .replaceAll("\t", "\\t"); -// Exec values are unescaped twice by implementations: first the general -// string-value rules, then the Exec quoting rules — so writing composes the -// layers in reverse. +// Exec values undergo general unescaping then Exec unquoting; escape in reverse order. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -108,9 +101,7 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } -// Some docks and bars look the Wayland app id up in the icon theme when no -// visible desktop entry matches the window, for example an AppImage run without -// integration. Theme lookups are case-sensitive, so install both spellings. +// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; function linuxThemeIconNames(desktopEntryName: string): readonly string[] { @@ -128,7 +119,7 @@ export class DesktopLinuxUrlHandler extends Context.Service< } >()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -164,8 +155,7 @@ export const make = Effect.gen(function* () { ...(environment.isPackaged ? { iconPath } : {}), ...(agentsLauncher ? { wmClass: environment.linuxWmClass } : {}), }); - // Pre-ready setup normally wrote this already. Avoid truncating a valid - // entry while the portal may be reading it during startup. + // Avoid truncating the pre-ready entry while the portal may be reading it. const existing = yield* fileSystem .readFileString(desktopEntryPath) .pipe(Effect.orElseSucceed(() => null)); @@ -249,10 +239,8 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFile(target, icon); changed = true; } - // Only keep an existing cache current. Creating one would leave GTK reading - // a cache that other tools installing user icons do not update. An icon - // newer than the cache also retries a refresh that failed on an earlier - // launch. + // Creating a cache would leave GTK reading stale icons from other installers. + // A newer icon retries a refresh that failed on an earlier launch. const cachePath = environment.path.join(hicolorDir, "icon-theme.cache"); const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option); if (Option.isNone(cachedAt)) return; @@ -351,10 +339,7 @@ export const make = Effect.gen(function* () { } yield* updateDesktopDatabase.pipe( - // Some MIME implementations, including GIO, use mimeinfo.cache to verify - // that a desktop entry is associated with a scheme. Cache refresh is - // independently best-effort so a missing update-desktop-database executable - // does not prevent xdg-mime from recording the requested default. + // GIO verifies scheme associations through mimeinfo.cache. A missing cache updater must not block xdg-mime. Effect.catch((error) => logWarning("desktop MIME cache refresh failed", { applicationsDir: environment.linuxApplicationsDir, diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6ddb31cb6eaac..ab8ea0b8e9d6e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -342,4 +342,36 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); + + it.effect("describes the Node runtime and the desktop mode under semantic or t3code keys", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); + assert.include( + body, + `"key":"process.runtime.version","value":{"stringValue":"${process.versions.node}"}`, + ); + assert.include(body, '"key":"t3code.desktop.mode","value":{"stringValue":"development"}'); + assert.notInclude(body, "service.mode"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 55d200a159177..3cd3936a59bfc 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,14 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -428,8 +431,7 @@ const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { - // Primary keeps the historical "server-child.log" path so ops scripts - // and packaged-build log inspection still find it where it always lived. + // Keep the primary log path stable for ops scripts and packaged-build inspection. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } @@ -437,8 +439,7 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Cacheable by resolved file path so two ids that sanitize to the same filename -// share a single RotatingLogFileWriter (no race on currentSize tracking). +// IDs sharing a sanitized filename must share a writer to avoid currentSize races. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -458,90 +459,91 @@ const makeBackendOutputLogShape = ( ): Effect.Effect => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), - onSome: (logFile) => - Effect.gen(function* () { - const sessionRef = yield* Ref.make(Option.none()); - const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( - function* (session: BackendOutputSession, details: string) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output start", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "START", - details: session.startDetails, - }, - }); - for (const output of session.chunks) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: output.streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - stream: output.streamName, - text: textDecoder.decode(output.chunk.subarray(output.offset)), - }, - }); - } - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output end", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "END", - details: sanitizeLogValue(details), - }, - }); + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (logFile) { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(function* ( + session: BackendOutputSession, + details: string, + ) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output start", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "START", + details: session.startDetails, }, - ); - return { - beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ - details, - }) { - const runId = yield* currentDesktopRunId; - yield* Ref.set( - sessionRef, - Option.some({ - runId, - startDetails: sanitizeLogValue(details), - chunks: [], - byteLength: 0, - }), - ); - }), - writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - yield* Ref.update( - sessionRef, - Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), - ); - }), - persistFailureSnapshot: Effect.fn( - "desktop.observability.backendOutput.persistFailureSnapshot", - )(function* ({ details }) { - const session = yield* Ref.get(sessionRef); - if (Option.isSome(session)) { - yield* writeFailure(session.value, details); - } - }), - persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( - function* ({ details }) { - const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); - if (Option.isNone(session)) return; - yield* writeFailure(session.value, details); + }); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), }, - ), - discardSession: Ref.set(sessionRef, Option.none()), - } satisfies DesktopBackendOutputLogShape; - }), + }); + } + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output end", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "END", + details: sanitizeLogValue(details), + }, + }); + }); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(function* ({ + details, + }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const layerBackendOutputLogFactory = Layer.effect( @@ -588,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); @@ -597,28 +601,28 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + "t3code.desktop.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), + ...nodeProcessRuntimeAttributes(), }, }; - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the main process wants has to be named in this one call. - // Splitting the OTLP logger back out into a layer of its own silently - // drops either it or the console output. - // Swapping `Logger.tracerLogger` out for the OTLP logger matches the - // server: both reach a collector, but the tracer logger covers only - // messages logged inside a recorded span and files them under traces, - // while the OTLP logger carries every message as a log record stamped - // with its trace and span ids. Keeping both would export every in-span - // message twice. + // Logger.layer replaces the logger set. tracerLogger would duplicate OTLP in-span messages. const layerLogger = Logger.layer( endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 5af13f94413f5..6a7473e2f1b1a 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -32,9 +32,7 @@ const syncFs = (method: string, path: string, run: () => A) => }), }); -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = FileSystem.makeNoop({ exists: (path) => syncFs("exists", path, () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 4179ec0636ede..8bef7366e378a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -49,7 +49,7 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; @@ -119,8 +119,7 @@ export const make = Effect.gen(function* () { "utf8", ); } catch {} - // Chromium caches its portal registration during startup. Set the identity - // before any asynchronous work can initialize it with Electron's default. + // Chromium caches portal identity; set it before any asynchronous initialization. Electron.app.setDesktopName(linux.linuxDesktopEntryName); Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { @@ -149,8 +148,7 @@ export const make = Effect.gen(function* () { }); }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); -// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot -// observe app readiness before scheme privileges and command-line switches exist. +// Complete scheme privileges and switches before later layers can observe app readiness. export const layer = Layer.mergeAll( ElectronProtocol.layerSchemePrivileges, Layer.effect(DesktopPreReadyElectronOptions, make), diff --git a/apps/desktop/src/app/DesktopRealtimeLimit.ts b/apps/desktop/src/app/DesktopRealtimeLimit.ts index bf5ce998f1091..7799ea77fec2a 100644 --- a/apps/desktop/src/app/DesktopRealtimeLimit.ts +++ b/apps/desktop/src/app/DesktopRealtimeLimit.ts @@ -17,18 +17,8 @@ export function parseRealtimeTimeoutHardLimit(limits: string): number | null | u return Number.isSafeInteger(hard) ? hard : undefined; } -/** - * PipeWire's client RT module sets RLIMIT_RTTIME, soft and hard, to the - * RTTimeUSecMax that xdg-desktop-portal reports, and the portal reports 0 - * without rtkit. Chromium loads libpipewire for Wayland screen capture. At 0 - * the kernel SIGKILLs a process as soon as one of its threads' CPU-time - * timers fires, and every child (the backend, provider CLIs) inherits the - * limit, which no unprivileged process can raise again. DISABLE_RTKIT makes - * PipeWire skip the portal and rtkit, so it leaves the limit alone. - * - * A limit inherited from the launcher (a desktop shell that loaded PipeWire) - * is already 0 here; only the user's session can fix that, so say so. - */ +// PipeWire without rtkit can set an inherited hard RTTIME limit of zero, causing SIGKILLs. +// DISABLE_RTKIT prevents that; a zero limit inherited from the launcher needs session repair. export const protectRealtimeLimit = Effect.fn("desktop.realtimeLimit.protect")(function* () { process.env.DISABLE_RTKIT ??= "1"; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts new file mode 100644 index 0000000000000..d8a0ef1a999cc --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -0,0 +1,70 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts"; +import { resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; + +const updateState = (patch: Partial): DesktopUpdateState => ({ + ...createInitialDesktopUpdateState( + "0.1.3", + { hostArch: "arm64", appArch: "arm64", runningUnderArm64Translation: false }, + "latest", + ), + enabled: true, + ...patch, +}); + +describe("resolveStartupRecoveryOffer", () => { + it("offers a newer version before going back", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "available", availableVersion: "0.1.4" }), + "0.1.2", + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("offers a retained download after a later check fails", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ status: "error", errorContext: "check", downloadedVersion: "0.1.4" }), + null, + ), + ).toEqual({ _tag: "Update", version: "0.1.4" }); + }); + + it("waits for the update check before offering to go back", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "checking" }), "0.1.2")).toEqual({ + _tag: "Checking", + }); + }); + + it("offers the last version that started when no update exists", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2")).toEqual({ + _tag: "Reinstall", + version: "0.1.2", + }); + }); + + it("does not offer an update that already failed to apply here", () => { + expect( + resolveStartupRecoveryOffer( + updateState({ + status: "downloaded", + downloadedVersion: "0.1.4", + notAppliedVersion: "0.1.4", + }), + "0.1.2", + ), + ).toEqual({ _tag: "Reinstall", version: "0.1.2" }); + }); + + it("falls back to the releases page when this version never started", () => { + expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({ + _tag: "Releases", + }); + expect(resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), null)).toEqual({ + _tag: "Releases", + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts new file mode 100644 index 0000000000000..4d2dbcfb14409 --- /dev/null +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -0,0 +1,338 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type * as Electron from "electron"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopObservability from "./DesktopObservability.ts"; +import * as DesktopState from "./DesktopState.ts"; + +const RELEASES_URL = "https://github.com/pingdotgg/t3code/releases"; +const LAST_STARTED_VERSION_FILE = "desktop-last-started-version"; +// Buttons are plain links to this host; will-navigate cancels them and runs the action. +const ACTION_ORIGIN = "https://startup-recovery.invalid"; + +const { logInfo, logWarning } = DesktopObservability.makeComponentLogger( + "desktop-startup-recovery", +); + +export type StartupRecoveryOffer = + | { readonly _tag: "Checking" } + | { readonly _tag: "Update"; readonly version: string } + | { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null } + | { readonly _tag: "Reinstall"; readonly version: string } + | { readonly _tag: "Releases" }; + +const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const; +type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number]; + +/** Prefer a newer build, then the last build that started on this machine, then the releases page. */ +export function resolveStartupRecoveryOffer( + update: DesktopUpdateState, + lastStartedVersion: string | null, +): StartupRecoveryOffer { + if (update.status === "downloading" && update.availableVersion !== null) { + return { + _tag: "Downloading", + version: update.availableVersion, + percent: update.downloadPercent, + }; + } + const updateVersion = + update.downloadedVersion ?? (update.status === "available" ? update.availableVersion : null); + // A version that already failed to apply here would loop through install and relaunch. + if (updateVersion !== null && updateVersion !== update.notAppliedVersion) { + return { _tag: "Update", version: updateVersion }; + } + if (update.enabled && (update.status === "idle" || update.status === "checking")) { + return { _tag: "Checking" }; + } + if (lastStartedVersion !== null && lastStartedVersion !== update.currentVersion) { + return { _tag: "Reinstall", version: lastStartedVersion }; + } + return { _tag: "Releases" }; +} + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char, + ); + +const actionLink = (action: StartupRecoveryAction, label: string, primary = false) => + `${escapeHtml(label)}`; + +function describeOffer(offer: StartupRecoveryOffer): { text: string; action: string } { + switch (offer._tag) { + case "Checking": + return { text: "Checking for a newer version…", action: "" }; + case "Update": + return { + text: `Version ${offer.version} is available. It can contain a fix.`, + action: actionLink("update", `Update to ${offer.version}`, true), + }; + case "Downloading": + return { + text: `Downloading ${offer.version}${offer.percent === null ? "" : ` (${Math.floor(offer.percent / 10) * 10}%)`}. T3 Code restarts when the download is done.`, + action: "", + }; + case "Reinstall": + return { + text: `Version ${offer.version} started correctly on this computer. Download it and install it to go back.`, + action: actionLink("reinstall", `Download ${offer.version}`, true), + }; + case "Releases": + return { + text: "Get a different version from the releases page.", + action: actionLink("releases", "Open releases", true), + }; + } +} + +function buildStartupRecoveryHtml(input: { + readonly appVersion: string; + readonly failure: BackendStartupFailure; + readonly offer: StartupRecoveryOffer; + readonly logDir: string; +}): string { + const offer = describeOffer(input.offer); + const output = input.failure.output.trim(); + return `T3 Code could not start +

T3 Code could not start

+

${escapeHtml(input.failure.reason)}

+

${escapeHtml(offer.text)}

+
${offer.action}${actionLink("copy", "Copy details")}${actionLink("quit", "Quit")}
+
${escapeHtml(output.length > 0 ? output : "The server wrote no output.")}
+

Version ${escapeHtml(input.appVersion)}. T3 Code tries again in the background. Logs: ${escapeHtml(input.logDir)}

+`; +} + +/** Shows a self-contained error window while the primary backend fails to start, so a broken release still opens. */ +export const listen: Effect.Effect< + void, + never, + | DesktopState.DesktopState + | DesktopUpdates.DesktopUpdates + | DesktopEnvironment.DesktopEnvironment + | ElectronApp.ElectronApp + | ElectronShell.ElectronShell + | ElectronTheme.ElectronTheme + | ElectronWindow.ElectronWindow + | FileSystem.FileSystem + | Scope.Scope +> = Effect.gen(function* () { + const { primaryBackendStartup, quitting } = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const electronApp = yield* ElectronApp.ElectronApp; + const electronShell = yield* ElectronShell.ElectronShell; + const electronTheme = yield* ElectronTheme.ElectronTheme; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const runFork = Effect.runForkWith(yield* Effect.context()); + + const lastStartedVersionPath = environment.path.join( + environment.stateDir, + LAST_STARTED_VERSION_FILE, + ); + const lastStartedVersion = yield* fileSystem.readFileString(lastStartedVersionPath).pipe( + Effect.map((text) => text.trim() || null), + Effect.orElseSucceed(() => null), + ); + + const windowRef = yield* Ref.make(Option.none()); + // The failure that opened the window; later restarts do not reload its details. + const failureRef = yield* Ref.make(Option.none()); + const renderedHtmlRef = yield* Ref.make(""); + const checkedRef = yield* Ref.make(false); + const { latest, changes: updateChanges } = yield* updates.subscribe; + const updateStateRef = yield* Ref.make(latest); + + const offer = Ref.get(updateStateRef).pipe( + Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)), + ); + + const render = Effect.gen(function* () { + const window = yield* Ref.get(windowRef); + const failure = yield* Ref.get(failureRef); + if (Option.isNone(window) || Option.isNone(failure) || window.value.isDestroyed()) return; + const html = buildStartupRecoveryHtml({ + appVersion: environment.appVersion, + failure: failure.value, + offer: yield* offer, + logDir: environment.logDir, + }); + if ((yield* Ref.getAndSet(renderedHtmlRef, html)) === html) return; + void window.value.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + }); + + const runAction = Effect.fn("desktop.startupRecovery.runAction")(function* ( + action: StartupRecoveryAction, + ) { + yield* logInfo("startup recovery action", { action }); + const current = yield* offer; + switch (action) { + case "update": { + if (current._tag !== "Update") return; + const state = yield* updates.getState; + if (state.downloadedVersion === null) { + const download = yield* updates.download; + if (!download.completed) return; + } + // Waits for a running background check; plain install would refuse. + yield* updates.installPrepared(current.version); + return; + } + case "reinstall": + if (current._tag === "Reinstall") { + yield* electronShell.openExternal(`${RELEASES_URL}/tag/v${current.version}`); + } + return; + case "releases": + yield* electronShell.openExternal(RELEASES_URL); + return; + case "copy": { + const failure = yield* Ref.get(failureRef); + if (Option.isNone(failure)) return; + yield* electronShell.copyText( + `T3 Code ${environment.appVersion} (${environment.platform} ${environment.processArch}) could not start.\n${failure.value.reason}\n\n${failure.value.output}`, + ); + return; + } + case "quit": + yield* electronApp.quit; + return; + } + }); + + const show = Effect.fn("desktop.startupRecovery.show")( + function* () { + if (yield* Ref.get(quitting)) return; + // A backend that crashes after the app opened is reported by the app itself. + if (Option.isSome(yield* electronWindow.main)) return; + const existing = yield* Ref.get(windowRef); + if (Option.isSome(existing) && !existing.value.isDestroyed()) { + yield* electronWindow.reveal(existing.value); + return; + } + const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const window = yield* electronWindow.create( + { + width: 640, + height: 480, + minWidth: 420, + minHeight: 320, + show: false, + title: "T3 Code could not start", + autoHideMenuBar: true, + backgroundColor: shouldUseDarkColors ? "#000000" : "#ffffff", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + javascript: false, + }, + }, + { auxiliary: true }, + ); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, url) => { + event.preventDefault(); + const action = STARTUP_RECOVERY_ACTIONS.find((name) => url === `${ACTION_ORIGIN}/${name}`); + if (action !== undefined) runFork(runAction(action)); + }); + window.once("ready-to-show", () => { + if (!window.isDestroyed()) window.show(); + }); + window.once("closed", () => { + runFork( + Ref.set(windowRef, Option.none()).pipe(Effect.andThen(Ref.set(renderedHtmlRef, ""))), + ); + }); + yield* Ref.set(windowRef, Option.some(window)); + yield* render; + yield* logInfo("startup recovery window shown"); + }, + Effect.catch((error) => + logWarning("could not show startup recovery", { errorTag: error._tag }), + ), + ); + + const close = Effect.gen(function* () { + const window = yield* Ref.getAndSet(windowRef, Option.none()); + if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close(); + }); + + const rememberStartedVersion = Effect.gen(function* () { + if (lastStartedVersion === environment.appVersion) return; + yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion); + }).pipe( + Effect.catch((error) => + logWarning("could not record the started version", { errorTag: error._tag }), + ), + ); + + yield* updateChanges.pipe( + Stream.runForEach((state) => Ref.set(updateStateRef, state).pipe(Effect.andThen(render))), + Effect.forkScoped, + ); + + yield* SubscriptionRef.changes(primaryBackendStartup).pipe( + Stream.runForEach( + Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) { + if (startup._tag === "Ready") { + yield* Ref.set(failureRef, Option.none()); + yield* close; + yield* rememberStartedVersion; + return; + } + if (startup._tag !== "Failing") return; + if (Option.isSome(yield* Ref.get(failureRef))) return; + yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output })); + // The backend manager already logs each failure's reason. + yield* logWarning("primary backend failing to start"); + yield* show(); + if (!(yield* Ref.getAndSet(checkedRef, true))) { + yield* updates.check("startup-recovery").pipe(Effect.forkScoped); + } + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("startup recovery stopped", cause).pipe( + Effect.annotateLogs({ component: "desktop-startup-recovery" }), + ), + ), + Effect.forkScoped, + ); + + // macOS keeps running with no windows; the Dock icon must bring the error back. + yield* electronApp.on("activate", () => { + runFork( + Ref.get(failureRef).pipe( + Effect.flatMap((failure) => (Option.isSome(failure) ? show() : Effect.void)), + ), + ); + }); +}); diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index cd2abe9106544..3452fd13c0807 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -2,18 +2,29 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; + +/** Startup health of the primary local backend, read by the startup recovery window. */ +export type PrimaryBackendStartup = + | { readonly _tag: "Starting" } + | { readonly _tag: "Ready" } + | ({ readonly _tag: "Failing" } & BackendStartupFailure); export class DesktopState extends Context.Service< DesktopState, { readonly backendReady: Ref.Ref; readonly quitting: Ref.Ref; + readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef; } >()("@t3tools/desktop/app/DesktopState") {} const make = Effect.all({ backendReady: Ref.make(false), quitting: Ref.make(false), + primaryBackendStartup: SubscriptionRef.make({ _tag: "Starting" }), }); export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9e87cfb7a9c02..9b5bc89148dea 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -26,7 +25,7 @@ const makeWindow = () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), } as unknown as ElectronWindow.ElectronWindow["Service"]), ), diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index f448f184b893f..ce63a710f7f7e 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -69,16 +69,15 @@ const make = Effect.gen(function* () { }); return DesktopWebLinks.of({ - receive: (url) => - Effect.suspend(() => { + receive: Effect.fn("DesktopWebLinks.receive")(function (url) { + return Effect.suspend(() => { pending.push(url); return flush; - }).pipe(Effect.withSpan("DesktopWebLinks.receive")), - setRendererReady: (ready) => - (ready ? startListening : Effect.sync(stopListening)).pipe( - Effect.andThen(flush), - Effect.withSpan("DesktopWebLinks.setRendererReady"), - ), + }); + }), + setRendererReady: Effect.fn("DesktopWebLinks.setRendererReady")(function (ready) { + return ready ? startListening : Effect.sync(stopListening); + }, Effect.andThen(flush)), }); }); diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index 98de2e91eb063..51beceb9e463f 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -4,12 +4,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -/** - * It reads the database files directly and never takes LevelDB's lock, so it - * works while another app version has the profile open. Live files come from - * the MANIFEST that CURRENT names, and the newest sequence number wins, which - * is how LevelDB itself resolves overwrites and deletions. - */ +/** Read without LevelDB's lock so another app can keep the profile open. */ const LOG_BLOCK_SIZE = 32 * 1024; const LOG_HEADER_SIZE = 7; @@ -145,11 +140,7 @@ function decompressSnappy(input: Uint8Array): Uint8Array { return output; } -/** - * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of - * its block, since a reused log keeps appending after a torn write, and a - * record cut off by the end of the file ends the log. - */ +/** Corruption skips the remaining block; a truncated final record ends the log, matching LevelDB recovery. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let pending: Uint8Array[] | null = null; @@ -422,9 +413,7 @@ export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage. }).pipe(wrap); } - // A flush while another process has the database open can move entries - // from a log we read into a table our MANIFEST copy predates. Fail so the - // caller retries rather than import a snapshot missing those entries. + // Concurrent flushes can move entries beyond this MANIFEST snapshot; detect changes so the caller retries. const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791f..7464d8fbe9e2f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -678,7 +678,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -688,10 +688,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -701,6 +703,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -722,6 +726,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index dadbe8acc743b..492903139547b 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -74,27 +74,24 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_NO_BROWSER", "T3CODE_HOST", "T3CODE_DESKTOP_WS_URL", - "T3CODE_DESKTOP_LAN_ACCESS", "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. +// Windows forwards these variables into WSL only through WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. "T3CODE_OTEL_SDK_DISABLED", "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", - // Forwarded without a WSLENV flag, so the values arrive untranslated. The - // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs - // travel as variables to keep winning inside the distro as they do on Windows. + // Untranslated T3 URLs must override bootstrap endpoints inside WSL, as they do on Windows. "T3CODE_OTLP_TRACES_URL", "T3CODE_OTLP_METRICS_URL", "T3CODE_OTLP_LOGS_URL", @@ -113,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -145,10 +143,7 @@ const mergeWslEnv = ( const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); - // Preserve the user's WSLENV exactly as Windows handed it to us — empty - // "::" segments and duplicate entries are harmless no-ops to WSL and not - // ours to normalize — and only append the secrets we need to forward - // across the wsl.exe boundary. + // Preserve empty segments and duplicates in the user's WSLENV; only append required variables. const parts = [existing, ...additions].filter((part) => part.length > 0); return parts.length > 0 ? parts.join(":") : undefined; }; @@ -253,10 +248,7 @@ type WslPreflightRuntime = } | { readonly kind: "node-script"; - // Absolute path to the node binary the preflight validated after the - // shared remote resolver repaired PATH. The launch must use this exact - // path so it doesn't fall through to a different/old node than the one - // node-pty was probed with. + // Launch the exact Node path probed with node-pty, even if later PATH lookup would resolve another version. readonly nodePath: string; readonly linuxEntryPath: string; }; @@ -397,8 +389,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime supplies its own Node and node-pty. Provider PATH - // discovery must not require either dependency for runtime readiness. + // Staged runtimes bundle Node and node-pty; provider PATH discovery must not require them. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; @@ -438,10 +429,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f nodePtyOptions, ); if (!nodePtyResult.ok) { - // Substituting the staged verdict for a transient mounted failure would - // turn a retryable failure into a fatal one, ending the WSL attempt (and, - // in wsl-only mode, persisting Windows) before the slow /mnt path had a - // chance to answer and clear the bad cache. + // Only substitute a staged failure for a fatal mounted failure; transient /mnt failures must remain retryable. return stagedFailure && nodePtyResult.fatal ? failedStaged(stagedFailure) : failedNodePty(nodePtyResult); @@ -464,16 +452,12 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the -// signature we use to detect that mode and switch the renderer URL to loopback. +// In WSL2 mirrored mode, eth0 matches a host IP; use loopback for the renderer. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { - // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on - // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -520,7 +504,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // Failed Windows probes leave server hydration enabled for another attempt. shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, @@ -537,10 +520,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - // Packaged builds only, so a dev instance never shares the cache with the - // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, - // so the setting does not leak into the provider and terminal processes - // the backend starts. + // Packaged builds have a separate cache; --require prevents cache settings leaking to provider subprocesses. args: [ ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), environment.backendEntryPath, @@ -581,25 +561,18 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // wslhost forwarding is unreliable on some Windows hosts: the desktop's - // readiness probe and the renderer's saved-env-style fetch both saw "Failed - // to fetch" when the backend only bound to 127.0.0.1 inside WSL. Binding to - // 0.0.0.0 plus advertising the WSL IP as the renderer-visible URL avoids that - // dependency. + // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; const bootstrap = { mode: "desktop" as const, noBrowser: true, port: input.port, - // Omit t3Home so the Linux backend uses its own home dir instead of - // the Windows-side baseDir (which would be a /mnt/c path and share - // the SQLite file with the primary). + // Omit t3Home to keep the Linux backend from sharing the primary's Windows SQLite file. host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // PortSchema rejects 0, so when tailscale serve is disabled we still need a - // valid number in this slot. + // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, ...buildObservabilityFragment(input.observabilitySettings), @@ -640,9 +613,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl allowBuild: !environment.isPackaged, }); - // Every operation after preflight uses the same concrete distro. In - // default-tracking mode this closes the race where the system default - // changes between probing and spawning the backend. + // Pin the preflight distro so a default change cannot race backend startup. const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; @@ -685,8 +656,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env is already a complete process.env minus T3CODE_HOME; pass it - // verbatim instead of letting the spawner re-merge process.env on top. + // env already excludes T3CODE_HOME; merging process.env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, @@ -695,8 +665,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...(runningDistro !== null ? { runningDistro } : {}), }; - // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. + // Explicit --dev-url keeps WSL state in dev/ rather than userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -716,10 +685,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; } - // The WSL server spawns commands its providers reference by name — `npm`/`npx` - // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on - // the process PATH, so `npm install -g ...` fails with NotFound. + // wsl.exe --exec omits the login-shell PATH needed to find provider CLIs and npm. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -747,7 +713,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -757,12 +723,7 @@ export const make = Effect.gen(function* () { const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; - // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. - // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can - // resolve concurrently; with a plain Ref both could observe None, generate - // distinct secrets, and one would overwrite the other — leaving the two - // backends deriving mismatched tokens and breaking the shared-token invariant - // the renderer relies on. + // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -833,18 +794,11 @@ export const make = Effect.gen(function* () { ); }); - // Dispatch happens at resolve time so toggling wsl-only between restarts - // is picked up on the next start cycle (the pool's primary instance is - // created once at layer init, but configResolve fires on each restart). + // Resolve on each restart so toggling wsl-only takes effect without recreating the pool. const describePrimary = Effect.gen(function* () { const persistedSettings = yield* settings.get; const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; - // Only honor wsl-only when WSL is actually usable. If the user - // persisted wsl-only but WSL has since become unavailable (wsl.exe - // removed, no distro), fall back to the Windows primary instead of - // looping forever on preflight failures: the Connections backend - // control is hidden while WSL is unavailable, so a stuck WSL primary - // would otherwise leave no in-app way back to Windows. + // When WSL is unavailable its controls are hidden; fall back to Windows to keep recovery possible. const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 690fb262c969a..635eae11d3449 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -125,6 +125,9 @@ interface MakeInstanceInput { readonly onShutdown?: Effect.Effect; readonly onStateDirOwned?: Effect.Effect; readonly onDatabaseRefused?: Effect.Effect; + readonly onStartupFailure?: ( + failure: DesktopBackendManager.BackendStartupFailure, + ) => Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; @@ -185,6 +188,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), ...(input.onDatabaseRefused ? { onDatabaseRefused: () => input.onDatabaseRefused! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), + ...(input.onStartupFailure ? { onStartupFailure: input.onStartupFailure } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -692,4 +696,56 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("reports a crash loop with the server output on the third restart", () => + Effect.scoped( + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, + ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), + ), + ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); + }, + }, + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, 3); + assert.equal(failure.reason, "The server could not start (code=1)."); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e7ac827199c21..33cd3ec3d1fdb 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -42,11 +42,13 @@ const STATE_DIR_OWNED_RESTART_ATTEMPTS = 6; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Same as the service launcher. A killed backend leaves its agents running, -// and a busy one needs a few seconds to stop every provider session. +// Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; +// Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help. +const STARTUP_FAILURE_RESTART_ATTEMPTS = 3; +const STARTUP_FAILURE_OUTPUT_CHARS = 4_000; const { logWarning: logBackendProcessWarning } = DesktopObservability.makeComponentLogger("desktop-backend-process"); @@ -68,9 +70,7 @@ export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; export interface DesktopBackendStartConfig extends BackendProcessContext { readonly args: ReadonlyArray; readonly env: Record; - // When true the spawner merges the desktop process.env on top of `env`; - // when false `env` is passed verbatim. WSL mode opts out so a leaking - // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + /** False passes env verbatim, preventing the Windows T3CODE_HOME from leaking into WSL. */ readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; @@ -236,6 +236,12 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId( PRIMARY_LOCAL_ENVIRONMENT_ID, ); +export interface BackendStartupFailure { + readonly reason: string; + /** Tail of the last run's stdout and stderr. */ + readonly output: string; +} + export interface DesktopBackendInstance { readonly id: BackendInstanceId; readonly label: Effect.Effect; @@ -250,16 +256,14 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - // Receives the *resolved* httpBaseUrl of the run that just became - // ready. The window service uses this to decide what URL to load - // (the WSL backend reports its distro IP, the Windows backend reports - // 127.0.0.1). Splitting this off from configResolve avoids races - // between "fired onReady" and "currentConfig already advanced". + /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; readonly onStateDirOwned?: () => Effect.Effect; readonly onDatabaseRefused?: () => Effect.Effect; + /** Fires on each repeated crash or readiness timeout until the backend becomes ready. */ + readonly onStartupFailure?: (failure: BackendStartupFailure) => Effect.Effect; } interface ActiveBackendRun { @@ -448,16 +452,12 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( cwd: options.cwd, env: options.env, extendEnv: options.extendEnv, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", stdout: options.captureOutput ? "pipe" : "inherit", stderr: options.captureOutput ? "pipe" : "inherit", killSignal: "SIGTERM", forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - // wsl.exe drops additional file descriptors when forwarding to the Linux - // side, so the WSL spawn path delivers the bootstrap envelope via stdin - // (`--bootstrap-fd 0`) instead. + // wsl.exe drops extra file descriptors; deliver its bootstrap via stdin. ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), }); @@ -559,12 +559,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - // Probe readiness in a loop while the backend process is still alive instead - // of giving up after the first budget. A slow cold boot (the WSL bundle - // loading across /mnt/c, or a first launch right after an update) can exceed - // the initial readiness budget while the backend is about to come up moments - // later; a one-shot probe left the app stuck on "Connecting to WSL…" forever - // even though the backend kept running and became healthy. + // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, @@ -652,6 +647,14 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); + const outputTail = yield* Ref.make(""); + // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. + const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() }); + let outputDecoders = makeOutputDecoders(); + const reportStartupFailure = (reason: string) => + Ref.get(outputTail).pipe( + Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void), + ); const { logWarning: logInstanceWarning, logError: logInstanceError } = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); @@ -920,6 +923,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, pid: Option.some(pid), })); + yield* Ref.set(outputTail, ""); + outputDecoders = makeOutputDecoders(); yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); @@ -968,9 +973,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* backendOutputLog.persistFailureSnapshot({ details: error.message, }); + yield* reportStartupFailure("The server started but did not become ready in time."); }, ), - onOutput: (streamName, chunk) => backendOutputLog.writeOutputChunk(streamName, chunk), + onOutput: (streamName, chunk) => + Ref.update(outputTail, (tail) => + // A cut through a surrogate pair would make encodeURIComponent throw when rendering. + (tail + outputDecoders[streamName].decode(chunk, { stream: true })) + .slice(-STARTUP_FAILURE_OUTPUT_CHARS) + .toWellFormed(), + ).pipe(Effect.andThen(backendOutputLog.writeOutputChunk(streamName, chunk))), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -996,26 +1008,29 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ) { const scheduled = yield* Ref.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { - return [Option.none(), latest] as const; + return [Option.none<{ delay: Duration.Duration; attempt: number }>(), latest] as const; } - const delay = calculateRestartDelay(latest.restartAttempt); + const attempt = latest.restartAttempt + 1; return [ - Option.some(delay), - { - ...latest, - restartAttempt: latest.restartAttempt + 1, - }, + Option.some({ delay: calculateRestartDelay(latest.restartAttempt), attempt }), + { ...latest, restartAttempt: attempt }, ] as const; }); yield* Option.match(scheduled, { onNone: () => Effect.void, - onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* (delay) { + onSome: Effect.fn("desktop.backendInstance.scheduleRestartFiber")(function* ({ + delay, + attempt, + }) { yield* logInstanceError("backend exited unexpectedly; restart scheduled", { reason, delayMs: Duration.toMillis(delay), }); + if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) { + yield* reportStartupFailure(`The server could not start (${reason}).`); + } const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( @@ -1088,58 +1103,58 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => - Effect.gen(function* () { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } - const cleanup = yield* mutex.withPermits(1)( - Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly needsCleanup: boolean; - readonly shouldStart: boolean; - }, - BackendManagerState, - ] => { - const current = Option.getOrUndefined(latest.active); - if (current?.id !== run.id) { - return [ - { - needsCleanup: false, - shouldStart: - latest.desiredRunning && - Option.isNone(latest.active) && - Option.isNone(latest.restartFiber), - }, - latest, - ]; - } + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (run) { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { return [ { - needsCleanup: true, - shouldStart: latest.desiredRunning, - }, - { - ...latest, - active: Option.none(), + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), }, + latest, ]; - }, - ), - ); - if (cleanup.needsCleanup) { - yield* desktopTelemetryPublisher.removeControlSource(spec.id); - yield* backendOutputLog.discardSession; - } - if (cleanup.shouldStart) { - yield* start; - } - }), + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index a8576a2b9e99e..2b2dd841ab53f 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -9,6 +9,7 @@ import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -62,6 +63,7 @@ function layerPool(labelRef: Ref.Ref): Layer.Layer { const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - // A transient preflight failure leaves a restart fiber scheduled - // without needing a real child process. configResolve: Effect.succeed({ executablePath: "/electron", args: [], diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index e24342a5461cf..dcf29b7dd3d43 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as FileSystem from "effect/FileSystem"; @@ -15,6 +16,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopBrowserHost from "../preview/DesktopBrowserHost.ts"; @@ -106,16 +108,12 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Without this, instance scopes are orphaned: they only close via explicit unregister() calls, - // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of - // receiving the graceful SIGTERM + grace period the instance's stop finalizer would otherwise - // run. + const { primaryBackendStartup } = yield* DesktopState.DesktopState; + // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. const layerScope = yield* Scope.Scope; const factoryContext = yield* Effect.context(); - // Fatal configuration failures persist the Windows fallback. Bounded - // transport failures use an in-memory fallback for this launch so the app - // opens without overwriting the user's WSL preference. + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; @@ -137,11 +135,10 @@ export const layer = Layer.effect( }); yield* electronDialog.showErrorBox( "WSL backend couldn't start", - `${reason}\n\nFalling back to the Windows backend so T3 Agents can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, + `${reason}\n\nFalling back to the Windows backend so T3 Agents can open. Re-enable the WSL backend from Settings > Sharing & access once the WSL distro is fixed.`, ); - // Clearing wslBackendEnabled alone would leave a stale wslOnly:true that silently re-traps - // the user in wsl-only mode the next time they enable WSL. If the persisted write fails, - // keep this process recoverable by applying the fallback to in-memory settings. + // Clear wslOnly too so enabling WSL cannot restore a failed mode. + // Keep the in-memory fallback if persistence fails. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -158,22 +155,22 @@ export const layer = Layer.effect( const primary = yield* DesktopBackendManager.makeBackendInstance({ id: DesktopBackendManager.PRIMARY_INSTANCE_ID, - // Keep this lazy. The pool layer is initialized before startup loads - // persisted desktop settings, so resolving the primary label here would - // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only - // primaries as Windows. + // Resolve lazily: startup loads persisted settings after pool initialization. label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, - // Window creation errors propagating out of handleBackendReady must not block the readiness - // callback (that would prevent restartAttempt from being reset), so we absorb them here. + // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( + // Only once the main window exists, so the recovery window never closes into nothing. + Effect.andThen(SubscriptionRef.set(primaryBackendStartup, { _tag: "Ready" })), Effect.catch((error) => logBackendPoolWarning("failed to open main window after backend readiness", { error: error.message, }), ), ), + onStartupFailure: (failure) => + SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), onShutdown: () => desktopWindow.handleBackendNotReady, onPreflightFailed: handlePrimaryPreflightFailure, onStateDirOwned: () => @@ -226,10 +223,7 @@ export const layer = Layer.effect( ] as const); } return Effect.gen(function* () { - // Provide the child scope after the captured factory context. That - // context also holds the pool layer's Scope, so providing it last - // would let it shadow the child scope and leave the instance's - // stop finalizer and restart fibers running after unregister. + // Provide the child Scope last so the captured pool Scope cannot shadow it and orphan stop/restart finalizers. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( Scope.provide(instanceScope), @@ -311,13 +305,14 @@ export const layer = Layer.effect( }); return DesktopBackendPool.of({ - get: (id) => - SynchronizedRef.get(instancesRef).pipe( + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), - ), + ); + }), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => @@ -346,11 +341,17 @@ export const layerTest = ( ); const primary = instances[0]!; return DesktopBackendPool.of({ - get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return Effect.succeed(Option.fromNullishOr(byId.get(id))); + }), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), - register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), - unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + register: Effect.fn("DesktopBackendPool.register")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support register"); + }), + unregister: Effect.fn("DesktopBackendPool.unregister")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support unregister"); + }), }); }), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 16c0d43616997..0dd3b03e7f807 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -63,7 +63,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< } >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; @@ -85,9 +85,7 @@ export const make = Effect.gen(function* () { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; - // A backend launched with the desktop secret accepts the current - // window's token, not the one frozen into its launch config; this - // exchange can run long after launch (e.g. after a suspend). + // Secret-authenticated backends accept the current window token; the launch token may be stale. const secret = config.bootstrap.desktopBootstrapSecret; const credential = secret === undefined diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f9a69692a2504..8d178c605c4a6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -71,15 +71,7 @@ const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); -/** - * Bridges, tunnels and VM adapters whose private address other devices on the - * LAN usually cannot reach: macOS's Thunderbolt Bridge (`bridge0`, which can - * keep a manual address while nothing is plugged in) and Internet Sharing - * bridges, VPN tunnels, and container or VM networks. Their addresses are only - * advertised when no other interface has one. Host bridges such as `br0`, - * Proxmox's `vmbr0` and Hyper-V's `vEthernet` external switches often carry - * the LAN address, so they are not listed. - */ +// Private bridges and VM adapters are fallbacks; host bridges can carry the real LAN address. const HOST_ONLY_INTERFACE = /^(bridge\d|utun|ipsec|ppp|tun|tap|wg|docker|br-|veth|virbr|vmnet|vboxnet|VirtualBox|VMware)/; @@ -441,7 +433,7 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; @@ -449,9 +441,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - // Each change reads the runtime state, persists settings, then writes the - // state back. Run them one at a time so a change never writes over another - // with values it read before that change persisted. + // Serialize read-persist-write changes so stale runtime state cannot overwrite another update. const changePermit = yield* Semaphore.make(1); // Key status by interface addresses so network changes bypass stale DNS and @@ -573,9 +563,7 @@ export const make = Effect.gen(function* () { customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. + // Spawning Mac App Store Tailscale can prompt for TCC access even when exposure is disabled. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 044625a32b445..4b75afccdb5bc 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -33,8 +33,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const [interfaceName, interfaceAddresses] of Object.entries(input.networkInterfaces)) { if (!interfaceAddresses) continue; - // CGNAT space is shared with other VPNs. Native adapter identity also - // works when the CLI is missing or its cached status predates a connection. + // CGNAT is shared with other VPNs; adapter identity works without current CLI status. const isTailscaleInterface = /^tailscale(?:\d+|ipv[46])?$/iu.test(interfaceName) || interfaceAddresses.some( diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index a8abdad9bbc60..f2524f246c664 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,2 @@ -// Enables the compile cache before the main bundle loads, so the cache also -// covers main.cjs itself. require("./compileCache.cjs"); require("./main.cjs"); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index da0fd66f5e582..abf8918df2af0 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -3,10 +3,8 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Linux uses the user's cache dir because /tmp is shared between users; the macOS and Windows -// temp dirs are already per user. Skipped under AppImage: it mounts the app at a new -// /tmp/.mount_* path each launch, and Node keys entries by path, so every launch would miss -// and leave another copy behind. +// Linux /tmp is shared; macOS and Windows temp dirs are per user. +// AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { if (!process.env.APPIMAGE) { const cacheRoot = diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0aae1148a4828..04f26b1bced84 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,12 +43,7 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; - /** - * The OS locale, read from the operating system rather than from Chromium's - * resolved application locale — the packaged app ships only the `en-US` - * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. - */ + /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; @@ -98,7 +93,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ @@ -127,9 +122,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. + // NSLocale returns en_GB-style tags; Intl requires hyphens. systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; @@ -142,59 +135,73 @@ export const make = ElectronApp.of({ quit: Effect.sync(() => { Electron.app.quit(); }), - exit: (code) => - Effect.sync(() => { + exit: Effect.fn("ElectronApp.exit")(function (code) { + return Effect.sync(() => { Electron.app.exit(code); - }), - relaunch: (options) => - Effect.sync(() => { + }); + }), + relaunch: Effect.fn("ElectronApp.relaunch")(function (options) { + return Effect.sync(() => { Electron.app.relaunch(options); - }), - setPath: (name, path) => - Effect.sync(() => { + }); + }), + setPath: Effect.fn("ElectronApp.setPath")(function (name, path) { + return Effect.sync(() => { Electron.app.setPath(name, path); - }), - setName: (name) => - Effect.sync(() => { + }); + }), + setName: Effect.fn("ElectronApp.setName")(function (name) { + return Effect.sync(() => { Electron.app.setName(name); - }), - setAboutPanelOptions: (options) => - Effect.sync(() => { + }); + }), + setAboutPanelOptions: Effect.fn("ElectronApp.setAboutPanelOptions")(function (options) { + return Effect.sync(() => { Electron.app.setAboutPanelOptions(options); - }), - setAppUserModelId: (id) => - Effect.sync(() => { + }); + }), + setAppUserModelId: Effect.fn("ElectronApp.setAppUserModelId")(function (id) { + return Effect.sync(() => { Electron.app.setAppUserModelId(id); - }), + }); + }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); - } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); - }), - setDesktopName: (desktopName) => - Effect.sync(() => { + setAsDefaultProtocolClient: Effect.fn("ElectronApp.setAsDefaultProtocolClient")( + function (protocol, path, args) { + return Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }); + }, + ), + setDesktopName: Effect.fn("ElectronApp.setDesktopName")(function (desktopName) { + return Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); - }), - setDockIcon: (iconPath) => - Effect.sync(() => { + }); + }), + setDockIcon: Effect.fn("ElectronApp.setDockIcon")(function (iconPath) { + return Effect.sync(() => { Electron.app.dock?.setIcon(iconPath); - }), - appendCommandLineSwitch: (switchName, value) => - Effect.sync(() => { - if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); - return; - } - Electron.app.commandLine.appendSwitch(switchName, value); - }), - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( + }); + }), + appendCommandLineSwitch: Effect.fn("ElectronApp.appendCommandLineSwitch")( + function (switchName, value) { + return Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }); + }, + ), + onBeforeQuitForUpdate: Effect.fn("ElectronApp.onBeforeQuitForUpdate")(function (listener) { + return Effect.acquireRelease( Effect.sync(() => { Electron.autoUpdater.on("before-quit-for-update", listener); }), @@ -202,11 +209,13 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), - ).pipe(Effect.asVoid), - removeCommandLineSwitch: (switchName) => - Effect.sync(() => { + ).pipe(Effect.asVoid); + }), + removeCommandLineSwitch: Effect.fn("ElectronApp.removeCommandLineSwitch")(function (switchName) { + return Effect.sync(() => { Electron.app.commandLine.removeSwitch(switchName); - }), + }); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 764ee6aa9971e..f2b07cee6db0f 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -104,7 +104,7 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const ownerWindowId = Option.match(input.owner, { @@ -166,8 +166,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -181,13 +181,11 @@ export const make = ElectronDialog.of({ buttonCount: options.buttons?.length ?? 0, cause, }), - }), - // The native error box runs a nested loop that holds the main process, and - // with it SIGTERM handling, until someone dismisses it: under a supervisor a - // stop then waits out its timeout and ends in SIGKILL. Electron allows only - // the native box before `ready`. - showErrorBox: (title, content) => - Effect.tryPromise({ + }); + }), + // Native error boxes block signal handling after ready; use the asynchronous dialog. + showErrorBox: Effect.fn("ElectronDialog.showErrorBox")(function (title, content) { + return Effect.tryPromise({ try: async () => { if (!Electron.app.isReady()) return Electron.dialog.showErrorBox(title, content); await Electron.dialog.showMessageBox({ @@ -203,7 +201,8 @@ export const make = ElectronDialog.of({ contentLength: content.length, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 352a5bc97a21a..d4338b45e280b 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -68,8 +68,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } - // Header items are decorative section labels for the web fallback only — - // Electron's native menu has no equivalent affordance, so we skip them. + // Native menus cannot display the web fallback's decorative headers. if (sourceItem.header === true) { continue; } @@ -97,9 +96,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } -// Renderer positions arrive in CSS pixels; popup() expects window points, so -// page zoom must be factored in or menus drift proportionally to their -// distance from the window origin. +// popup() takes window points; renderer CSS coordinates must account for page zoom. const normalizePosition = ( position: Option.Option, zoomFactor: number, @@ -112,7 +109,7 @@ const normalizePosition = ( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; @@ -190,8 +187,8 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.try({ + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -203,9 +200,10 @@ export const make = Effect.gen(function* () { itemCount: template.length, cause, }), - }).pipe(Effect.orDie), - popupTemplate: (input) => - input.template.length === 0 + }).pipe(Effect.orDie); + }), + popupTemplate: Effect.fn("ElectronMenu.popupTemplate")(function (input) { + return input.template.length === 0 ? Effect.void : Effect.try({ try: () => @@ -221,9 +219,10 @@ export const make = Effect.gen(function* () { itemCount: input.template.length, cause, }), - }).pipe(Effect.orDie), - showContextMenu: (input) => - Effect.callback>((resume) => { + }).pipe(Effect.orDie); + }), + showContextMenu: Effect.fn("ElectronMenu.showContextMenu")(function (input) { + return Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeedNone); @@ -275,7 +274,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 4fb42dfdc9f47..5a702b61a8111 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -75,12 +75,15 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] ).pipe(Effect.asVoid); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), - getSystemIdleState: (idleThresholdSeconds) => - Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getSystemIdleState: Effect.fn("ElectronPowerMonitor.getSystemIdleState")( + function (idleThresholdSeconds) { + return Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)); + }, + ), getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), onSimpleEvent, onThermalStateChange, diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d6f476205f4c5..d84b6f360c89e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -78,11 +78,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "https://challenges.cloudflare.com", ]; - // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment - // origins are not known when this response policy is created, so restrict - // connections by the network schemes the client supports instead of by host. - // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + // User-configured environment origins are unknown here, so allow supported network schemes. + // GLTFLoader fetches embedded textures through blob URLs. const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ @@ -110,9 +107,7 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } -/** - * Must run synchronously during process bootstrap, before Electron emits `ready`. - */ +// Must run synchronously before Electron emits ready. function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { @@ -123,8 +118,7 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, - // Custom schemes skip Chromium's V8 code cache unless they opt in. - // Dev stays off: Vite serves changing code at stable URLs. + // Custom schemes must opt into V8 caching; dev URLs serve changing code. codeCache: true, }, }, @@ -246,7 +240,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const context = yield* Effect.context(); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index e727c902edd7c..b290cfa29e97f 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -66,7 +66,7 @@ export class ElectronSafeStorage extends Context.Service< } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; @@ -75,16 +75,18 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - encryptString: (value) => - Effect.try({ + encryptString: Effect.fn("ElectronSafeStorage.encryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.tryPromise({ + }); + }), + decryptString: Effect.fn("ElectronSafeStorage.decryptString")(function (value) { + return Effect.tryPromise({ try: () => decryptSharedString(value), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), + }); + }), selectedStorageBackend: Effect.sync(() => { if (platform !== "linux") { return Option.none(); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0216d7b2c7dd1..e32dba36a6dfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -11,9 +11,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must -// reach the OS handler; every other non-web scheme stays blocked. +// Remote editor deep links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -63,10 +61,10 @@ export class ElectronShell extends Context.Service< } >()("@t3tools/desktop/electron/ElectronShell") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: Effect.fn("ElectronShell.openExternal")(function (rawUrl) { + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => @@ -75,16 +73,19 @@ export const make = ElectronShell.of({ () => false, ), ), - }), - openSystemSettings: (pane) => - Effect.promise(() => + }); + }), + openSystemSettings: Effect.fn("ElectronShell.openSystemSettings")(function (pane) { + return Effect.promise(() => Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), - ), - copyText: (text) => - Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..ab0673b817ae2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -28,18 +28,19 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: (theme) => - Effect.try({ + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ try: () => { Electron.nativeTheme.themeSource = theme; }, catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }), - onUpdated: (listener) => - Effect.acquireRelease( + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { Electron.nativeTheme.on("updated", listener); return Effect.void; @@ -49,7 +50,8 @@ export const make = ElectronTheme.of({ Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), - ), + ); + }), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4..c71b42040db29 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -80,49 +80,59 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { + setFeedURL: Effect.fn("ElectronUpdater.setFeedURL")(function (options) { + return Effect.suspend(() => { autoUpdater.setFeedURL(options); return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { + }); + }), + setAutoDownload: Effect.fn("ElectronUpdater.setAutoDownload")(function (value) { + return Effect.suspend(() => { autoUpdater.autoDownload = value; return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + }); + }), + setAutoInstallOnAppQuit: Effect.fn("ElectronUpdater.setAutoInstallOnAppQuit")(function (value) { + return Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { + }); + }), + setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { + return Effect.suspend(() => { autoUpdater.channel = channel; return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { + }); + }), + setAllowPrerelease: Effect.fn("ElectronUpdater.setAllowPrerelease")(function (value) { + return Effect.suspend(() => { autoUpdater.allowPrerelease = value; return Effect.void; - }), + }); + }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { + setAllowDowngrade: Effect.fn("ElectronUpdater.setAllowDowngrade")(function (value) { + return Effect.suspend(() => { autoUpdater.allowDowngrade = value; return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { + }); + }), + setFullChangelog: Effect.fn("ElectronUpdater.setFullChangelog")(function (value) { + return Effect.suspend(() => { autoUpdater.fullChangelog = value; return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), + }); + }), + setDisableDifferentialDownload: Effect.fn("ElectronUpdater.setDisableDifferentialDownload")( + function (value) { + return Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }); + }, + ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -137,8 +147,11 @@ export const make = ElectronUpdater.of({ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), }).pipe(Effect.asVoid); }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + quitAndInstall: Effect.fn("ElectronUpdater.quitAndInstall")(function ({ + isSilent, + isForceRunAfter, + }) { + return Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -150,8 +163,9 @@ export const make = ElectronUpdater.of({ cause, }), }); - }), - on: (eventName, listener) => { + }); + }), + on: Effect.fn("ElectronUpdater.on")(function (eventName, listener) { const eventTarget = autoUpdater as unknown as { on: (eventName: string, listener: (...args: Array) => void) => void; removeListener: (eventName: string, listener: (...args: Array) => void) => void; @@ -166,7 +180,7 @@ export const make = ElectronUpdater.of({ eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid); - }, + }), }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 4f17ca7e33010..478d490129014 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -162,4 +162,20 @@ describe("ElectronWindow", () => { assert.equal(vi.mocked(laterWindow.destroy).mock.calls.length, 1); }).pipe(Effect.provide(layerTest)), ); + + it.effect("never falls back to an auxiliary window as the main window", () => + Effect.gen(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const auxiliary = yield* electronWindow.create({}, { auxiliary: true }); + const ordinary = { isDestroyed: () => false } as unknown as Electron.BrowserWindow; + Object.assign(auxiliary, { isDestroyed: () => false }); + + getAllWindowsMock.mockReturnValue([auxiliary]); + assert.isTrue((yield* electronWindow.currentMainOrFirst)._tag === "None"); + + getAllWindowsMock.mockReturnValue([auxiliary, ordinary]); + const current = yield* electronWindow.currentMainOrFirst; + assert.isTrue(current._tag === "Some" && current.value === ordinary); + }).pipe(Effect.provide(layerTest)), + ); }); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 07c2acad9e957..238b6ca94d264 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -109,8 +109,10 @@ export class ElectronWindowOperationError extends Schema.TaggedError Effect.Effect; readonly main: Effect.Effect>; readonly currentMainOrFirst: Effect.Effect>; @@ -130,11 +132,10 @@ export class ElectronWindow extends Context.Service< } >()("@t3tools/desktop/electron/ElectronWindow") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; - // The focus worker loads a native accessibility module. Start it on the first - // capture reveal so users who never capture pay nothing at launch. + // Lazy-load native accessibility on capture reveal to avoid startup cost for other users. let windowsForegroundFocus: ReturnType | undefined; const ensureWindowsForegroundFocus = () => { windowsForegroundFocus ??= startWindowsForegroundFocusThread( @@ -145,6 +146,7 @@ export const make = Effect.gen(function* () { const captureRevealWindows = new Set(); yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); + const auxiliaryWindows = new WeakSet(); const listWindows = Effect.try({ try: () => Electron.BrowserWindow.getAllWindows(), @@ -185,7 +187,9 @@ export const make = Effect.gen(function* () { return main; } - const first = Option.fromNullishOr((yield* listWindows)[0] ?? null); + const first = Option.fromNullishOr( + (yield* listWindows).find((window) => !auxiliaryWindows.has(window)) ?? null, + ); if (Option.isNone(first) || (yield* isWindowDestroyed(first.value))) { return Option.none(); } @@ -211,7 +215,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: (options) => { + create: Effect.fn("ElectronWindow.create")(function (options, behavior) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -236,16 +240,22 @@ export const make = Effect.gen(function* () { } satisfies typeof ElectronWindowCreateOptions.Type; return Effect.try({ - try: () => new Electron.BrowserWindow(options), + try: () => { + const window = new Electron.BrowserWindow(options); + if (behavior?.auxiliary === true) auxiliaryWindows.add(window); + return window; + }, catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); - }, + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, - setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), - clearMain: (window) => - Ref.update(mainWindowRef, (current) => { + setMain: Effect.fn("ElectronWindow.setMain")(function (window) { + return Ref.set(mainWindowRef, Option.some(window)); + }), + clearMain: Effect.fn("ElectronWindow.clearMain")(function (window) { + return Ref.update(mainWindowRef, (current) => { if (Option.isNone(current)) { return current; } @@ -253,17 +263,19 @@ export const make = Effect.gen(function* () { return current; } return Option.none(); - }), - fromWebContents: (sender) => - Effect.sync(() => + }); + }), + fromWebContents: Effect.fn("ElectronWindow.fromWebContents")(function (sender) { + return Effect.sync(() => Option.fromNullishOr( Electron.BrowserWindow.getAllWindows().find( (window) => window.webContents.id === sender.id, ), ), - ), - prepareReveal: (window) => - Effect.promise(async () => { + ); + }), + prepareReveal: Effect.fn("ElectronWindow.prepareReveal")(function (window) { + return Effect.promise(async () => { if (platform !== "win32" || window.isDestroyed()) { return false; } @@ -271,9 +283,10 @@ export const make = Effect.gen(function* () { return ensureWindowsForegroundFocus() .prepare(windowsForegroundFocusTarget(window)) .catch(() => false); - }), - reveal: (window) => - Effect.tryPromise({ + }); + }), + reveal: Effect.fn("ElectronWindow.reveal")(function (window) { + return Effect.tryPromise({ try: async () => { if (window.isDestroyed()) { return; @@ -334,7 +347,8 @@ export const make = Effect.gen(function* () { channel: null, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), sendAll: /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ Effect.fnUntraced(function* (channel, ...args) { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 48d04304c852e..5f7409ca59bc2 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -5,11 +5,7 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { - clearConnectionCatalog, - getConnectionCatalog, - setConnectionCatalog, -} from "./methods/connectionCatalog.ts"; +import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled, @@ -27,7 +23,6 @@ import { ensureSshEnvironment, fetchSshEnvironmentDescriptor, fetchSshSessionState, - issueSshWebSocketTicket, resolveSshHost, resolveSshPasswordPrompt, } from "./methods/sshEnvironment.ts"; @@ -128,7 +123,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); - yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); @@ -137,7 +131,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(fetchSshEnvironmentDescriptor); yield* ipc.handle(bootstrapSshBearerSession); yield* ipc.handle(fetchSshSessionState); - yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); yield* ipc.handle(getServerExposureState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 8e2be41aa7a26..497299451843f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -58,7 +58,6 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; -export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; @@ -66,7 +65,6 @@ export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-enviro export const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -export const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; @@ -113,7 +111,6 @@ export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-fra export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; -export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 4e51496a6379a..0f913d0f300a3 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,13 +25,3 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); - -export const clearConnectionCatalog = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, - payload: Schema.Void, - result: Schema.Void, - handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { - const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - yield* store.clear; - }), -}); diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts index 13f657e7812f8..a0c2fb27a5eac 100644 --- a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -22,8 +22,7 @@ export const completeLegacyLocalStorage = makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; - // Chromium writes localStorage to disk lazily; persist the merge before - // the marker stops later launches from retrying it. + // Flush Chromium's lazy writes before the marker prevents migration retries. if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); yield* legacy.complete; }), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c23..20b7bb4877d59 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -47,9 +47,6 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeStateChanges((tabId, state) => electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), ); - yield* manager.subscribeRecordingFrames((frame) => - electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), - ); yield* manager.subscribeRecordingInputs((event) => electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_INPUT_CHANNEL, event), ); @@ -262,11 +259,7 @@ const clearCache = DesktopIpc.makeIpcMethod({ }), }); -/** - * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an orphaned - * partition. - */ +/** Keep the default profile's bare environment id so existing logins retain their partition. */ export function resolvePartitionScope( environmentId: string, profileId: string | undefined, @@ -278,9 +271,7 @@ export function resolvePartitionScope( if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { return { scope: environmentId, persistent: true }; } - // JSON's tuple framing is injective for strings, including lone UTF-16 - // surrogates (which it escapes). URI encoding throws on those supported ids, - // while replacing them with U+FFFD would collapse distinct identities. + // JSON framing preserves lone UTF-16 surrogates; URI encoding throws and replacement would collide. return { scope: JSON.stringify([environmentId, profileId]), persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, @@ -288,11 +279,7 @@ export function resolvePartitionScope( }; } -/** - * Clearing without a profile keeps the historical "everything" behaviour for - * an explicit all-profiles action; naming a profile confines it to that - * profile's partition so one profile's sign-out cannot reach the others. - */ +/** Omitting a profile clears every partition; a named profile clears only its own partition. */ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( manager: PreviewManager.PreviewManager["Service"], environmentId: string, @@ -300,10 +287,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit ) { if (profileId === undefined) return undefined; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Loading the session is what puts the partition in the map the clear walks. - // Deriving the partition string alone leaves nothing to match, so clearing a - // profile with no tab open this run — after a restart, or when deleting a - // profile — would report success and delete nothing. + // getBrowserSession registers the partition; deriving its name alone leaves unopened profiles uncleared. yield* manager.getBrowserSession(scope, persistent, namespace); return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); @@ -315,9 +299,7 @@ const getPreviewConfig = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Creating the session first is what installs the UA rewrite and permission - // handlers; a guest that attached to an untouched partition would run with - // Electron's default UA and Chromium's default permission behaviour. + // Session creation installs UA and permission handlers before a guest can attach. yield* manager.getBrowserSession(scope, persistent, namespace); return { partition: yield* manager.getBrowserPartition(scope, persistent, namespace), @@ -346,8 +328,6 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ ...importInput }) { const browserImport = yield* BrowserImport.BrowserImport; - // Derived in main from the same helper the webview config uses, so cookies - // land in exactly the partition the profile's tabs attach to. const { scope, persistent, namespace } = resolvePartitionScope( environmentId, importInput.targetProfileId, diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 099b97ce7bf31..15e04f71282b1 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,7 +1,6 @@ import { bootstrapRemoteBearerSession, fetchRemoteSessionState, - issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; @@ -24,7 +23,6 @@ import { EnvironmentScopeRequiredError, AuthAccessTokenResult, AuthSessionState, - AuthWebSocketTicketResult, } from "@t3tools/contracts"; import { SshHttpBridgeError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; @@ -40,8 +38,7 @@ import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts. type DesktopSshEnvironmentRequestOperation = | "fetch-environment-descriptor" | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; + | "fetch-session-state"; type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; @@ -203,23 +200,6 @@ export const fetchSshSessionState = DesktopIpc.makeIpcMethod({ }), }); -export const issueSshWebSocketTicket = DesktopIpc.makeIpcMethod({ - channel: IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - payload: DesktopSshBearerRequestInputSchema, - result: AuthWebSocketTicketResult, - handler: Effect.fn("desktop.ipc.sshEnvironment.issueWebSocketTicket")(function* ({ - httpBaseUrl, - bearerToken, - }) { - return yield* withLoopbackSshApi("issue-websocket-ticket", (resolvedHttpBaseUrl) => - issueRemoteWebSocketTicket({ - httpBaseUrl: resolvedHttpBaseUrl, - bearerToken, - }), - )(httpBaseUrl); - }), -}); - export const resolveSshPasswordPrompt = DesktopIpc.makeIpcMethod({ channel: IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, payload: DesktopSshPasswordPromptResolutionInputSchema, diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 915509b5d4f55..19327c3db5983 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -20,11 +20,14 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { revealWindow, getLocalEnvironmentBootstraps, getRelayTelemetryEnabled, + pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -227,7 +230,7 @@ describe("revealWindow", () => { const window = { isDestroyed: () => false } as Electron.BrowserWindow; const sender = {} as Electron.WebContents; const reveal = vi.fn(() => Effect.void); - const fromWebContents = vi.fn(() => Effect.succeed(Option.some(window))); + const fromWebContents = vi.fn(() => Effect.succeedSome(window)); return Effect.gen(function* () { yield* revealWindow.handler(undefined, { sender }); assert.deepEqual(fromWebContents.mock.calls, [[sender]]); @@ -244,12 +247,84 @@ describe("revealWindow", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - fromWebContents: () => Effect.succeed(Option.none()), + fromWebContents: () => Effect.succeedNone, }), ), ), ); }); +describe("pickProjectFavicon", () => { + const pickerLayer = ( + pickFiles: () => Effect.Effect>, + settings?: DesktopAppSettings.DesktopSettings, + ) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeedNone, + }), + DesktopAppSettings.layerTest(settings), + ); + + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: [ + "avif", + "bmp", + "gif", + "ico", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + "icns", + ], + }, + ], + }, + ], + ]); + }), + ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); +}); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 43188c4d15d4a..20d866de3eda2 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -201,9 +201,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. + // Use the current window's derived token; the launch config's token may be stale. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -257,9 +255,7 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - // Fall back to the persisted wslDistro when the id is the - // "wsl:default" sentinel; the orchestrator uses the same fallback - // for the actual backend. + // Match the orchestrator's persisted distro fallback for wsl:default. const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; const defaultPath = useWsl ? Option.fromNullishOr( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 3c10f04512b4c..dac7550871460 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,8 +22,7 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Only enumerate distros when WSL is actually available — listDistros on a - // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + // Listing distros on non-WSL hosts spawns wsl.exe only to time out. const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { @@ -67,8 +66,7 @@ export const setWslBackendEnabled = makeIpcMethod({ yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); return state; } - // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. + // Reconcile never fails, so no rollback is needed. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -84,8 +82,7 @@ export const setWslDistro = makeIpcMethod({ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslDistro(distro); const settings = yield* appSettings.get; - // In active wsl-only mode the pool's primary IS the WSL backend, and its - // distro is captured when that backend starts, so relaunch to replace it. + // WSL-only primary backends capture their distro at startup; relaunch to change it. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); @@ -101,10 +98,7 @@ export const setWslOnly = makeIpcMethod({ payload: Schema.Boolean, result: DesktopWslStateSchema, handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { - // wsl-only decides which backend the pool spins up as "primary", and that - // decision is captured once at layer init. A disabled WSL backend always - // leaves Windows primary active, so mode changes can be staged without a - // relaunch and applied by the subsequent enable call. + // The pool captures its primary at initialization; disabled WSL can stage mode changes until enable. const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslOnly(enabled); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index aaf0c11bcce92..ce7bc4004cf4c 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,14 +26,12 @@ describe("linuxSecretStorage", () => { it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); - // Chromium stops at the first recognized value, so a later name cannot rescue the session. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); }); it("does not treat lowercase desktop names as ones Electron recognizes", () => { - // Chromium matches these case-sensitively, so lowercase spellings reach basic text. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 81f891985b9d0..b6d41b43a6640 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -8,8 +8,7 @@ export type LinuxPasswordStoreSwitch = Exclude layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 200379528b408..20c2d4036f9b6 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -88,8 +88,7 @@ export class MacPermissionHelper { const bundle = macAppBundlePath(Electron.app.getPath("exe")); if (!bundle) return; if (owner?.isDestroyed()) return; - // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts. - // Use the same PNG that packaging uses to generate the app's macOS icon. + // Finder can return generic icons for mounted artifacts; use the packaging PNG. const appIcon = iconPaths .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) .find((image) => !image.isEmpty()); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index f529d82b59413..e5acc09abbf92 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -15,9 +15,8 @@ const SettingsWindow = Schema.NullOr( export type SettingsWindow = typeof SettingsWindow.Type; const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow)); -// Window bounds and owner PIDs are available before Screen Recording is granted. -// Use the bundle identifier rather than the localized app/window title. A single -// process avoids launching osascript repeatedly while the user moves Settings. +// Window bounds/PIDs need no Screen Recording grant. Match the bundle id to avoid localized titles. +// Keep one osascript process while Settings moves. const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 22e365cf44c7f..e61417c9bc68a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -3,7 +3,6 @@ import type { DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, - DesktopPreviewRecordingFrame, DesktopPreviewTabState, DesktopSnapShotEvent, } from "@t3tools/contracts"; @@ -46,8 +45,7 @@ try { const clientPlatform = process.platform; if (clientPlatform === "darwin") { - // Native window buttons do not scale with Chromium zoom. Keep their reserved - // space in native points, including when a zoomed page is reloaded. + // Native buttons ignore Chromium zoom; reserve native points even after reload. const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", @@ -155,7 +153,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), - clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => @@ -176,8 +173,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { }), fetchSshSessionState: (httpBaseUrl, bearerToken) => ipcRenderer.invoke(IpcChannels.FETCH_SSH_SESSION_STATE_CHANNEL, { httpBaseUrl, bearerToken }), - issueSshWebSocketTicket: (httpBaseUrl, bearerToken) => - ipcRenderer.invoke(IpcChannels.ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, { httpBaseUrl, bearerToken }), onSshPasswordPrompt: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { if (typeof request !== "object" || request === null) return; @@ -414,15 +409,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { mimeType, data, }), - onFrame: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, frame: unknown) => { - if (typeof frame !== "object" || frame === null) return; - listener(frame as DesktopPreviewRecordingFrame); - }; - ipcRenderer.on(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - return () => - ipcRenderer.removeListener(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, wrappedListener); - }, }, onStateChange: (listener) => { const wrappedListener = ( diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 1104c422f3eea..f1f4be518455c 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -4,8 +4,6 @@ import { createAnnotationIdSource } from "./AnnotationIds.ts"; describe("createAnnotationIdSource", () => { it("gives annotations picked on separate page loads distinct ids", () => { - // The picker preload runs afresh on every page load, while the composer - // keeps earlier annotations keyed by id. const firstLoad = createAnnotationIdSource(); firstLoad("element"); const firstAnnotation = firstLoad("annotation"); diff --git a/apps/desktop/src/preview/AnnotationIds.ts b/apps/desktop/src/preview/AnnotationIds.ts index 2262febdd1c15..082d6540a64d8 100644 --- a/apps/desktop/src/preview/AnnotationIds.ts +++ b/apps/desktop/src/preview/AnnotationIds.ts @@ -1,11 +1,4 @@ -/** - * Id source for picker annotations and their targets, one per page load. - * - * The composer keeps annotations keyed by id across preview reloads, and this - * preload restarts on every page load, so a bare counter reissues ids that a - * draft already holds. `getRandomValues` rather than `randomUUID`, because - * plain-http previews (LAN, tailnet) are not secure contexts. - */ +/** Per-load nonces avoid draft ID collisions; getRandomValues works on plain HTTP. */ export function createAnnotationIdSource(): (prefix: string) => string { const pageLoad = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => part.toString(36), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 4815da11cba1a..c691ac15174d6 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -46,8 +46,6 @@ const withImporter = Effect.fnUntraced(function* () { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - // The cookie database is what marks a source as installed, so a fixture - // without one is reported as absent before any other check runs. yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( @@ -96,8 +94,6 @@ describe("BrowserImport.importCookies", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { importer, root } = yield* withImporter(); - // The lock Chromium leaves while it is running, dangling target and - // all. This must stop the import before it ever asks the keychain. yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); const error = yield* importer diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e7f97eca26c02..c567b27411c0c 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -41,8 +41,7 @@ export class BrowserImportFailedError extends Schema.TaggedError 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -152,7 +147,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; @@ -200,19 +195,14 @@ export const make = Effect.gen(function* BrowserImportMake() { } if (platform === "darwin" && definition.engine === "chromium") { - // macOS attributes the Keychain prompt and the resulting ACL grant to the - // executable that asks, so record which one that was — in a packaged build - // it is the signed app, in dev whatever binary hosts the main process. + // macOS attributes Keychain prompts and ACL grants to the executable, which differs in dev and packaged builds. yield* Effect.logInfo("Reading browser cookie key from the keychain", { sourceId: definition.id, executablePath, }); } - // The profile directory arrives over IPC, so it is only honoured when the - // source itself reported it. Forwarding it unchecked would let `..` - // segments walk out of the browser's user-data directory and read any - // cookie database reachable on disk. + // IPC profile paths must match the source's listing to prevent traversal outside browser user-data. const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); @@ -226,8 +216,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // The profile was listed against a database moments ago; resolve it again - // rather than assume a path, since a Chromium jar may sit under `Network/`. + // Re-resolve listed paths because Chromium jars may be under Network/. const databasePath = yield* resolveCookieDatabase( definition, pathContext, @@ -296,8 +285,7 @@ export const make = Effect.gen(function* BrowserImportMake() { ), ); - // Written one at a time rather than in parallel: Chromium's cookie store - // serialises writes anyway, and a rejected cookie should only cost itself. + // Chromium serializes writes; a rejected cookie should not prevent the others. return yield* writeCookies(session, result); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 91be0a43d1be8..e5aa05f268e43 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -38,9 +38,6 @@ const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { - // Chromium stores a host-only cookie without a leading dot. Passing any - // `domain` to Electron makes it a domain cookie and re-adds the dot, which - // would expose the cookie to every subdomain it was never scoped to. expect(cookieScope("example.test", "/", true)).toEqual({ url: "https://example.test/", domain: undefined, @@ -288,8 +285,7 @@ describe("readChromiumCookieDatabase", () => { const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); - // The key some Linux clients actually encrypted with (crbug.com/1195256): - // OSCrypt's derivation over an empty passphrase. + // Some Linux clients encrypted with an empty passphrase (crbug.com/1195256). const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); yield* Effect.gen(function* () { @@ -352,9 +348,6 @@ describe("readChromiumCookieDatabase", () => { ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // Chromium's OSCrypt returns unprefixed data as-is on both platforms - // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear - // text"), so neither counts it as undecryptable. const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d5f7592b26068..63fef9fb3c808 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -73,12 +73,7 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect( Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), ); -/** - * Unspecified is imported as Electron's own `unspecified` rather than pinned to - * Lax, so the target browser applies its default just as the source did; - * anything unrecognised lands there too, since guessing "none" would widen a - * cookie's scope on import. - */ +/** Preserve unspecified SameSite; guessing no_restriction would widen cookie scope. */ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 1) return "lax"; @@ -86,21 +81,14 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { return "unspecified"; }; -/** - * The microsecond value overflows JavaScript's safe integer range and - * `node:sqlite` refuses to narrow it, so the division happens in SQL and this - * only ever sees seconds. - */ +/** Convert microseconds in SQL: node:sqlite refuses integers beyond JavaScript's safe range. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { if (webkitSeconds <= 0) return undefined; return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; }; -/** - * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. - */ +// Chromium 127+ prefixes plaintext with the host-key SHA-256 to bind its domain. const stripDomainBinding = ( plaintext: Buffer, domain: string, @@ -161,17 +149,14 @@ export function decryptChromiumValue( const prefix = buffer.subarray(0, 3).toString("latin1"); const payload = buffer.subarray(3); - // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and - // intentionally have no key here, so they fall through as undecryptable. + // Windows v10 uses AES-256-GCM; app-bound v20 records have no key here. if (platform === "win32") { return prefix === "v10" && keys.gcmV10 ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) : null; } - // Chromium retries a failed record with a key derived from an empty - // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). + // Retry the empty-passphrase key used by some Linux clients (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 0d91ebe5a3746..bc9edcfe52a80 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -14,7 +14,6 @@ import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; const KEY_SALT = "saltysalt"; const KEY_LENGTH = 16; -/** macOS stretches the keychain secret; Linux uses a single iteration. */ const MAC_KEY_ITERATIONS = 1003; const LINUX_KEY_ITERATIONS = 1; /** Chromium's documented fallback passphrase when no Linux keyring is present. */ @@ -42,11 +41,7 @@ export interface ChromiumKeyMaterial { readonly cbcV10?: Buffer; readonly cbcV11?: Buffer; readonly cbcV11Error?: ChromiumKeyError; - /** - * AES-128-CBC from an empty passphrase. Some Linux clients wrote records - * with it (crbug.com/1195256), so Chromium — and this import — retry with it - * after a record's own key fails. - */ + /** Linux clients can encrypt with an empty passphrase; retry it on failure (crbug.com/1195256). */ readonly cbcEmpty?: Buffer; readonly gcmV10?: Buffer; } @@ -54,21 +49,12 @@ export interface ChromiumKeyMaterial { const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); -/** - * Via the CLI the prompt says "security" and "Always Allow" grants trust to a - * tool every process on the machine can invoke; in-process it names this app - * and the grant belongs to it. - * - * Deliberately untimed: macOS answers this with a modal, and a timeout racing - * the user means the prompt can be approved while nothing is left listening, - * which reads as "approving did nothing". - */ +// In-process Keychain access attributes grants to this app. Keep modal prompts untimed to preserve their listener. const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( service: string, account: string, ) { - // Only macOS cookie imports need this binding; loading it at startup can - // prevent the desktop from opening on platforms that never use it. + // Loading the macOS binding at startup can prevent other platforms from opening. const Keyring = yield* Effect.tryPromise({ try: () => import("@napi-rs/keyring"), catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), @@ -90,10 +76,7 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function return secret; }); -/** - * Its exit codes distinguish a missing key, denied access, and an unavailable - * keyring without parsing localized error messages. - */ +/** Exit codes distinguish missing keys, denied access, and unavailable keyrings without localized message parsing. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, ) { @@ -267,13 +250,10 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") } if (request.platform === "linux") { - // The fallback passphrase always applies to `v10` records; a keyring - // secret, when one is reachable, additionally unlocks `v11`. + // v10 always uses the fallback passphrase; a keyring secret additionally unlocks v11. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( - // v10 remains importable when Secret Service is absent or does not - // contain a key. An explicit denial/lock/cancel remains a consent - // failure rather than being silently downgraded. + // Missing Secret Service or keys must not block v10; explicit denial, lock, or cancellation remains a consent failure. Effect.catchIf( (error) => error.reason !== "needsKeychainApproval", (error) => Effect.succeed(error), diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 2eb6cdbb5a17b..6c708e6023df2 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -9,11 +9,7 @@ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; - /** - * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a - * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host - * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. - */ + /** Leave host-only cookies undefined; Electron widens any supplied domain and rejects __Host- cookies with one. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; @@ -47,10 +43,7 @@ export const cookieScope = ( export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); -/** - * Both engines keep the file open with WAL while the browser runs, so reading in place can observe - * a torn write. Copying also guarantees we never open the browser's own file for writing. - */ +/** Copy before reading to avoid live WAL writes and never open the source browser database for writing. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, tempPrefix = "t3code-cookie-import-", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index d764b5bd6d007..be41318fa01e6 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -37,8 +37,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rawSameSite?: number; originAttributes?: string; }>, - // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in - // milliseconds, earlier ones in seconds. schemaVersion = 15, ) { const fileSystem = yield* FileSystem.FileSystem; @@ -46,7 +44,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( const file = `${directory}/cookies.sqlite`; const database = new NodeSqlite.DatabaseSync(file); database.exec(`pragma user_version = ${schemaVersion}`); - // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; database.exec( `create table moz_cookies ( @@ -87,8 +84,6 @@ describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => run( Effect.gen(function* () { - // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older - // profiles still hold seconds. Both must land as seconds for Electron. const row = { host: "example.test", name: "c", @@ -169,9 +164,6 @@ describe("readFirefoxCookies", () => { it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => run( Effect.gen(function* () { - // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite - // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in - // to cross-site use; importing it as "none" would widen its scope. const row = { host: "example.test", name: "c", @@ -198,8 +190,6 @@ describe("readFirefoxCookies", () => { it.effect("imports rows whose SameSite was never written", () => run( Effect.gen(function* () { - // Schema 9 added `sameSite` without a default, so rows from before the - // upgrade hold NULL. One such row must not fail the whole import. const row = { host: "example.test", name: "c", @@ -229,10 +219,6 @@ describe("readFirefoxCookies", () => { it.effect("applies the schema-15 rawSameSite rule to older databases", () => run( Effect.gen(function* () { - // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value - // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by - // default, None declared" into Unset; an unmigrated database has to be - // read the same way or an undeclared cookie becomes an explicit Lax. const row = { host: "example.test", name: "c", @@ -276,9 +262,6 @@ describe("readFirefoxCookies", () => { sameSite: 1, }, { - // Same host, name and path as above: Firefox keeps these apart by - // container, Electron cannot, so importing both would hand the - // profile whichever one happened to be written last. host: "mail.test", name: "session", value: "work-container", @@ -340,8 +323,6 @@ describe("readFirefoxCookies", () => { describe("parseFirefoxProfiles", () => { it.effect("reads named profiles and ignores Install sections", () => Effect.gen(function* () { - // `Install*` sections name a default profile but do not describe one, so - // counting them would invent a profile whose directory does not exist. const parsed = yield* parsePosixFirefoxProfiles( [ "[Install4F96D1932A9F858E]", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 16ad488f5cc2f..a5fddb14d22df 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -17,23 +17,12 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Firefox isolates cookies per container and per private window via - // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); - // Electron has no equivalent, so importing them all would collapse - // several identities onto one host/name/path and hand the profile an - // arbitrary container's session. + // Electron cannot represent Firefox containers/private windows; importing them would collapse distinct sessions. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 5420b9fc8bd50..7e92334c957de 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -158,8 +158,6 @@ describe("parseBinaryCookies", () => { }); it("rejects a page that runs past the end of the file", () => { - // `Buffer.subarray` clamps rather than throwing, so an overlong first page - // swallows the second one's bytes and advances the cursor past the end. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -215,9 +213,6 @@ describe("parseBinaryCookies", () => { describe("readSafariCookies", () => { it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { - // What Full Disk Access actually looks like: the file is there, the read - // is refused with EPERM. Effect tags that `Unknown`, not - // `PermissionDenied`, so the reader has to look at the errno. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 15f8de8d3a36e..118f9af1f776a 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -8,7 +8,6 @@ import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; /** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; -/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ const COOKIE_PAGE_HEADER_SIZE = 12; const COOKIE_RECORD_HEADER_SIZE = 56; @@ -46,10 +45,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -74,9 +70,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray page.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } - // Every record accepted so far, so a later offset cannot point back into - // one of them: the page header, the offset table, and earlier records are - // all bytes that would otherwise parse as a fabricated cookie. + // Reject offsets into the header, table, or earlier records to avoid fabricated cookies. const accepted: Array = []; for (let index = 0; index < cookieCount; index += 1) { const cookieStart = page.readUInt32LE(8 + index * 4); @@ -120,9 +114,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; cookies.push({ - // Safari marks domain cookies with a leading dot like the other - // engines, so the shared scope rule applies: host-only cookies keep - // `domain` undefined, or Electron widens them to every subdomain. ...cookieScope(domain, path || "/", secure), name, value, @@ -130,17 +121,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { - // TCC denies with EPERM, which Effect tags `Unknown` rather than - // `PermissionDenied` — so the errno is what identifies it. EACCES (and the - // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or - // ACL failure that granting Full Disk Access cannot fix, so it stays a plain - // read failure rather than sending the user to a grant that won't help. + // TCC returns EPERM (Effect Unknown); EACCES is a POSIX denial that Full Disk Access cannot fix. const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; return code === "EPERM"; }; -/** - * `stat` succeeds on the jar inside Safari's container even without Full Disk - * Access — that is what lets the listing find it — so presence alone cannot - * tell granted from denied. - * */ +/** stat succeeds without Full Disk Access, so presence alone does not prove readable cookies. */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1bad2ec2b3572..8e1d2c3d62d4d 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -17,8 +17,7 @@ export const safariPermissionCheck = Effect.gen(function* () { if (!safari || context.platform !== "darwin") return false; const defaultJar = yield* resolveCookieDatabase(safari, context, "."); if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar); - // A Safari installation can have cookies only in a named profile. Rediscover - // those stores after a grant, since TCC may have hidden their metadata before. + // Rediscover named profiles after a grant; TCC may have hidden their metadata. const profiles = yield* listSourceProfiles(safari, context); for (const profile of profiles) { const jar = yield* resolveCookieDatabase(safari, context, profile.directory); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index cc92563c5847c..898d94221b8d4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -119,9 +119,6 @@ describe("isSourceRunning", () => { const context = yield* withSourceHome(); assert.isFalse(yield* isSourceRunning(helium, context)); - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", `${userDataDirectory(context)}/SingletonLock`, @@ -209,9 +206,6 @@ describe("isSourceInstalled", () => { const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Installers for native messaging hosts create an empty user-data - // directory for every Chromium fork they know about, so treating the - // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, context)); @@ -307,8 +301,6 @@ describe("cookieDatabaseCandidatePaths", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Chromium 96+ keeps sessions in Network/; a root Cookies left behind - // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); @@ -326,6 +318,237 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!; + +describe("Zen", () => { + it.effect("resolves Zen's native user-data roots on every supported platform", () => + run( + Effect.gen(function* () { + const darwinContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const windowsContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: "C:\\Users\\zen-user", + APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const linuxContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + + assert.equal(zen.name, "Zen"); + assert.equal(zen.engine, "firefox"); + assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]); + assert.equal( + zen.userDataDirectory(darwinContext), + darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"), + ); + assert.equal( + zen.userDataDirectory(windowsContext), + windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"), + ); + assert.equal( + zen.userDataDirectory(linuxContext), + linuxContext.path.join("/home/zen-user", ".config", "zen"), + ); + assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [ + linuxContext.path.join("/home/zen-user", ".zen"), + linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), + ]); + }), + ), + ); + + it.effect("discovers Zen profiles through its Firefox-compatible metadata", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const root = zen.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + const profile = context.path.join(root, "Profiles", "zen.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Zen", + "IsRelative=1", + "Path=Profiles/zen.default", + "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg", + "Default=1", + "", + "[Profile1]", + "Name=Empty", + "IsRelative=1", + "Path=Profiles/empty.default", + "", + ].join("\n"), + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { + directory: context.path.join("Profiles", "zen.default"), + name: "Zen", + cookieCount: 2, + }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.isFalse(yield* isSourceRunning(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const currentRoot = context.path.join(home, ".config", "zen"); + const legacyRoot = context.path.join(home, ".zen"); + const currentProfile = context.path.join(currentRoot, "current.default"); + const legacyProfile = context.path.join(legacyRoot, "legacy.default"); + + for (const [root, profile, name] of [ + [currentRoot, currentProfile, "Current"], + [legacyRoot, legacyProfile, "Legacy"], + ] as const) { + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + `Name=${name}`, + "IsRelative=1", + `Path=${context.path.basename(profile)}`, + ].join("\n"), + ); + } + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: "current.default", name: "Current", cookieCount: 1 }, + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, legacyProfile), + context.path.join(legacyProfile, "cookies.sqlite"), + ); + + yield* fileSystem.writeFileString( + context.path.join(currentRoot, "profiles.ini"), + `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`, + ); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Shared", cookieCount: 1 }, + ]); + + yield* fileSystem.remove(currentRoot, { recursive: true }); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + }), + ), + ); + + it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen"); + const profile = context.path.join(root, "flatpak.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n", + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: profile, name: "Flatpak", cookieCount: 2 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, profile), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("does not mistake Firefox Snap installs for Zen profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const zenRoot = context.path.join(home, ".config", "zen"); + const snapRoot = context.path.join( + home, + "snap", + "firefox", + "common", + ".mozilla", + "firefox", + ); + const zenProfile = context.path.join(zenRoot, "zen.default"); + const snapProfile = context.path.join(snapRoot, "snap.default"); + for (const root of [zenRoot, snapRoot]) { + const profile = root === zenRoot ? zenProfile : snapProfile; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Personal", + "IsRelative=1", + `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`, + ].join("\n"), + ); + } + + const profiles = yield* listSourceProfiles(zen, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["zen.default"], + ); + assert.isTrue(yield* isSourceInstalled(zen, context)); + }), + ), + ); +}); + describe("Firefox Snap profiles", () => { it.effect.skipIf(!symlinksSupported)( "finds Snap profiles with or without profiles.ini and checks their locks", @@ -565,14 +788,9 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // `.parentlock` is deliberately left on disk after a clean exit as a - // last-used marker, so an unlocked one is not evidence of a running - // browser — treating it as one blocked every import after first use. yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // The `lock` symlink is what Firefox removes on exit; a live pid in - // its target means the profile is held. yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); assert.isTrue(yield* isSourceRunning(firefox, context)); }), @@ -582,11 +800,7 @@ describe("isSourceRunning for Firefox", () => { it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); - // The resolver may hand Firefox any of the machine's addresses, not - // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address - // elsewhere — so every local address counts as ours. const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); - // Both the plain and the fcntl-marked (`+`) forms carry the pid. assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); @@ -603,8 +817,7 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - // Firefox's win32 root hangs off %APPDATA%; without it the root is - // undefined and the fixture would escape the sandbox into the repo. + // Without APPDATA, the Windows Firefox fixture would escape into the repo. Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, @@ -616,8 +829,6 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.makeDirectory(profile, { recursive: true }); yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3e1cae508a69e..14c4bca77b7e6 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -26,6 +26,7 @@ export interface BrowserImportSourceDefinition { readonly engine: BrowserImportEngine; readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + readonly alternateLinuxRoots?: (context: BrowserImportPathContext) => ReadonlyArray; readonly keychainService?: string; readonly keychainAccount?: string; readonly linuxSecretApplication?: string; @@ -73,9 +74,7 @@ const chromiumSource = (input: { }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - // No Chromium fork is importable on Windows: since Chrome 127 their cookies - // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. + // Chrome 127+ App-Bound Encryption prevents other processes from reading Windows Chromium cookies. chromiumSource({ id: "chrome", name: "Chrome", @@ -171,6 +170,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ], + }, + { + id: "zen", + name: "Zen", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "zen"); + if (context.platform === "win32") { + return context.appData ? context.path.join(context.appData, "zen") : undefined; + } + return context.path.join(context.home, ".config", "zen"); + }, + alternateLinuxRoots: (context) => [ + context.path.join(context.home, ".zen"), + context.path.join(context.home, ".var", "app", "app.zen_browser.zen", "zen"), + ], }, ]; @@ -190,8 +209,7 @@ const cookieDatabaseCandidatePaths = ( if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } - // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade - // leaves the legacy file behind, so prefer the current one and fall back. + // Prefer Chromium 96+'s Network/Cookies; upgrades leave the pre-96 Cookies file behind. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), @@ -209,11 +227,7 @@ export const resolveCookieDatabase = Effect.fnUntraced(function* ( return undefined; }); -/** - * Firefox records its profiles in `profiles.ini`. `Install*` sections point at - * a default profile but do not describe one, so only `[ProfileN]` blocks - * count. - */ +/** Install* sections select defaults, not profiles; only ProfileN blocks describe profiles. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, @@ -242,8 +256,7 @@ export function parseFirefoxProfiles( if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { - // Firefox supports profiles on arbitrary custom roots when - // IsRelative=0. Do not constrain them to the standard Firefox root. + // IsRelative=0 permits custom roots outside Firefox's standard directory. directory = path.normalize(candidate); } @@ -308,10 +321,7 @@ const isSafeProfileDirectory = (directory: string): boolean => const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); -/** - * Firefox containers use identities Electron cannot represent, so its count - * uses the same default-container predicate as the reader. - */ +// Count only default-container cookies because Electron cannot represent Firefox containers. const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -420,12 +430,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. When - // none of the declared profiles has one, fall through to the scan rather - // than returning empty: `profiles.ini` can list stale or never-launched - // profiles while the cookies live in one it does not mention, and an empty - // answer here hides the browser entirely. + // Declared profiles may be stale or never launched; scan for unlisted stores if none has a cookie database. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -460,11 +465,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), - // The keys are directory names from the browser's own metadata file, which - // anything running as the user can write. Anything but a single plain - // segment is dropped: `..` or a path separator would otherwise be handed - // to `cookieDatabasePath` and read a database outside the user-data - // directory. + // Browser metadata is user-writable; reject traversal segments before resolving cookie database paths. Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), Effect.map((entries) => entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), @@ -490,11 +491,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** - * Include Firefox's Snap home alongside its native home. Snap profiles use - * absolute directories so cookie reads and lock checks keep pointing at the - * installation they came from, even when both installs use the same name. - */ +/** Keep alternate-root profile paths absolute so cookie reads and locks use the correct installation. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -505,10 +502,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; - const roots = [ - root, - context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), - ]; + const roots = [root, ...(definition.alternateLinuxRoots?.(context) ?? [])]; const profiles = new Map(); for (const directory of roots) { const found = yield* listSourceProfilesInDirectory( @@ -544,9 +538,7 @@ export const chromiumProcessIsAlive = ( signalProcess(pid, 0); return true; } catch (cause) { - // Only ESRCH positively proves the process is gone. Permission errors - // and unknown failures stay conservative so an active browser is never - // mistaken for a stale lock. + // Only ESRCH proves a stale lock; permission and unknown failures may still hide an active browser. return !( typeof cause === "object" && cause !== null && @@ -570,8 +562,7 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( if (!/^\d+$/.test(pidText)) return true; const pid = Number(pidText); if (!Number.isSafeInteger(pid) || pid <= 0) return true; - // A PID is meaningful only on this host. A foreign hostname can come from a - // shared home directory, and cannot safely be declared stale from here. + // Foreign-host PIDs may come from a shared home; never declare them stale here. if (host !== currentHost) return true; return yield* isProcessAlive(pid); }); @@ -580,15 +571,9 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; -/** - * Whether a Windows `parent.lock` is actually held by a running process. It - * is opened with no sharing, so it persists on disk after the process exits - * and `stat` always succeeds; only trying to open it for write reveals an - * active holder, which surfaces as `Busy`. - */ +/** Windows leaves parent.lock after exit; only opening it for write reveals a live holder as Busy. */ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { - // Permission failures are distinct: they do not prove a browser owns the - // lock, so they must not hide the source as running. + // Permission failures do not prove a running browser owns the lock. const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( Effect.as(false), @@ -600,11 +585,7 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { type WindowsLockProbe = (path: string) => Effect.Effect; -/** - * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The - * live cookie database is opened without sharing instead, so probing each - * profile's current jar is the reliable running signal there. - */ +/** Windows Chromium locks cookie databases without sharing instead of creating POSIX SingletonLock symlinks. */ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -628,9 +609,7 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( ) { const separator = target.lastIndexOf(":"); if (separator < 0) return true; - // The owner half is whatever Firefox's resolver returned for the machine's - // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN - // address — so a pid is only meaningful when that address is one of ours. + // Firefox resolves the hostname to loopback or LAN addresses; only local owners' PIDs are meaningful. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -641,18 +620,10 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); -/** - * `/usr/bin/python3` is named absolutely first so a Dock-launched app with - * launchd's bare `PATH` still finds it without depending on the login-shell - * PATH merge; Linux distributions carry python3 on the default path. - */ +/** Dock launches have a bare PATH; try the absolute interpreter before relying on shell discovery. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; -/** - * Anything else means the script never ran — most importantly Apple's - * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits - * non-zero after printing an install prompt, without ever reaching our code. - */ +/** Apple's python3 shim can fail with an install prompt before the probe script runs. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + @@ -664,12 +635,9 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Anything else — no interpreter on any candidate path, or one that refused to - * run the script (Apple's shim without the developer tools) — is the probe - * being unavailable, not evidence about the lock. That case falls back to "not - * held" rather than "held": reporting every profile as locked forever would - * block Firefox import outright on such machines, and the SQLite snapshot - * already copes with a live database's WAL, as it does for every other engine. + * A missing or refusing interpreter (Apple's shim without developer tools) means + * the probe is unavailable, so report "not held": "held" would block Firefox import + * forever, and the SQLite snapshot already copes with a live WAL. */ const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -699,12 +667,7 @@ const posixLockIsHeld = Effect.fnUntraced(function* ( return false; }); -/** - * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* after - * exit, as a last-used marker; treating them as proof of a running browser - * blocks every import after Firefox has been used once. - */ +/** Firefox leaves regular lock files after exit; test the lock, not file existence. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, @@ -739,8 +702,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Safari keeps no lock and writes its jar atomically, so a running instance - // is not a hazard there. + // Safari writes its jar atomically and needs no lock, even while running. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -766,18 +728,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") return found.some(Boolean); }); -/** - * Keyed off the cookie database rather than the user-data directory, because - * that directory is not evidence the browser exists: installers for native - * messaging hosts create an empty one for every Chromium fork they know about, - * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. - * - * Existence is checked without opening the file, which matters for Safari: TCC - * permits `stat` on the jar inside its container but refuses a read, so this - * still sees it and the user gets the Full Disk Access prompt rather than - * having Safari disappear. - */ +/** Require a cookie database; installers create empty directories. Safari stat needs no Full Disk Access. */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 45d2aeafdbfac..22af4a829e049 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -85,8 +85,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these - // two supported ids would hash to one partition and share every cookie. const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); @@ -116,13 +114,8 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so the - // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { - // Electron's real UA shape: app token, then Chrome, then Electron, then - // Safari — the token order and casing matter to any strip regex. const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; fromPartition.mockReset(); @@ -175,12 +168,7 @@ describe("BrowserSession", () => { return granted; }; - for (const permission of [ - "clipboard-read", - "clipboard-sanitized-write", - "geolocation", - "fullscreen", - ]) { + for (const permission of ["clipboard-sanitized-write", "geolocation", "fullscreen"]) { assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`); assert.isTrue( checkHandler(null, permission) as boolean, @@ -233,6 +221,55 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("asks once per origin before a page reads the clipboard", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("scope-a"); + yield* browserSessions.getSession("scope-a"); + const browserSession = sessions.get(partition); + assert.isDefined(browserSession); + const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0]; + const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0]; + assert.isFunction(requestHandler); + assert.isFunction(checkHandler); + + const readClipboard = (requestingUrl: string) => + Effect.callback((resume) => { + requestHandler( + null, + "clipboard-read", + (granted: boolean) => resume(Effect.succeed(granted)), + { requestingUrl, isMainFrame: true }, + ); + }); + const checkClipboard = (origin: string): boolean => + checkHandler(null, "clipboard-read", origin, {}) as boolean; + + showMessageBox.mockReset(); + showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1); + + assert.isFalse(checkClipboard("https://allowed.test")); + assert.isTrue(yield* readClipboard("https://allowed.test/page")); + assert.strictEqual(showMessageBox.mock.calls.length, 1); + assert.strictEqual( + showMessageBox.mock.calls[0]?.[0].message, + "https://allowed.test wants to see text and images copied to the clipboard", + ); + assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]); + // Enter must not accept a prompt the page raised while the user was typing. + assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1); + + assert.isTrue(yield* readClipboard("https://allowed.test/other")); + assert.isTrue(checkClipboard("https://allowed.test")); + + assert.isFalse(yield* readClipboard("https://blocked.test/")); + assert.isFalse(yield* readClipboard("https://blocked.test/again")); + assert.isFalse(checkClipboard("https://blocked.test")); + assert.isTrue(checkClipboard("https://allowed.test")); + assert.strictEqual(showMessageBox.mock.calls.length, 2); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears a partition whose session has not been opened yet", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 3847ec3323316..2ea7efdb88ee0 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -12,37 +12,20 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -/** - * Incognito partitions deliberately omit the `persist:` prefix, which is what - * makes Chromium keep them in memory and discard them with the process. They - * still carry the product prefix so `isPartition` can admit them — the - * `will-attach-webview` gate rejects anything it does not recognise. - */ +/** Ephemeral partitions omit persist: but retain a product prefix so the webview gate recognizes them. */ const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// `clipboard-sanitized-write` is the Electron permission behind -// `navigator.clipboard.writeText()` — note it is NOT `clipboard-write`, which -// is not a valid Electron permission name. Async clipboard writes are gated by -// the permission *check* handler (not only the request handler), so both -// handlers must allow it; otherwise built-in "Copy" buttons — e.g. the Next.js -// / Vercel error overlay — fail with `Failed to execute 'writeText' on -// 'Clipboard': Write permission denied`. +// Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ - "clipboard-read", "clipboard-sanitized-write", "geolocation", - // `DesktopWindow` disables HTML fullscreen window resizing for the app window - // and every preview guest, so a page that goes fullscreen fills its own - // webview instead of taking over the whole T3 window. + // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", - // Deliberately NOT local-fonts: preview sessions run untrusted web content, - // and silently granting it would hand every page the user's installed-font - // fingerprint (and font file bytes via FontData.blob()). - // Deliberately NOT notifications: a page could post OS notifications branded - // as T3 Code. T3's own come from the app window on the default session. + // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. + // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ @@ -71,6 +54,19 @@ const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => const MAX_PROMPT_URL_LENGTH = 300; +const truncateForPrompt = (text: string): string => + text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text; + +const permissionOrigin = (rawUrl: string | undefined): string | null => { + if (!rawUrl) return null; + try { + const { origin } = new URL(rawUrl); + return origin === "null" ? null : origin; + } catch { + return null; + } +}; + export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { @@ -166,12 +162,7 @@ const selectSessions = ( ([partition]) => partitions === undefined || partitions.includes(partition), ); -/** - * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` - * and `"p\ufffd"` would hash to the same partition and share cookies. Those - * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` - * first — and a literal backslash is doubled so the escape cannot be forged. - */ +/** Escape lone surrogates before UTF-8 hashing, which otherwise merges them with U+FFFD; escape backslashes too. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( scope @@ -182,10 +173,7 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); -/** - * A `` guest has no window of its own, so look it up through the page - * that embeds it. - */ +// Webview guests have no window; use their embedding page. const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; const host = guest.hostWebContents ?? guest; @@ -193,57 +181,76 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined return BrowserWindow.fromWebContents(host) ?? undefined; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. - let externalPromptOpen = false; - /** - * Electron grants an external-protocol navigation through the `openExternal` - * permission and then launches the OS handler itself. Ask first: previews run - * untrusted pages, and a silent grant would let any of them launch apps. - */ - const confirmOpenExternal = ( + let permissionPromptOpen = false; + /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */ + const promptPermission = ( requester: WebContents | null, - rawUrl: string | undefined, - callback: (granted: boolean) => void, + prompt: { + readonly buttons: readonly [allow: string, deny: string]; + readonly message: string; + readonly detail?: string; + }, + callback: (answer: boolean | undefined) => void, ): void => { - const url = externalProtocolPromptUrl(rawUrl); - if (url === null || externalPromptOpen) { - callback(false); + if (permissionPromptOpen) { + callback(undefined); return; } - externalPromptOpen = true; - const settle = (granted: boolean) => + permissionPromptOpen = true; + const settle = (answer: boolean | undefined) => Effect.sync(() => { - externalPromptOpen = false; - callback(granted); + permissionPromptOpen = false; + callback(answer); }); - const shownUrl = - url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; runFork( electronDialog .showMessageBox( { type: "question", - buttons: ["Open", "Cancel"], - defaultId: 0, + buttons: [...prompt.buttons], + // Default to deny so an Enter typed into the page cannot accept a prompt it raised. + defaultId: 1, cancelId: 1, - message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, - detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, + message: prompt.message, + ...(prompt.detail === undefined ? {} : { detail: prompt.detail }), }, previewHostWindow(requester), ) .pipe( - Effect.map(({ response }) => response === 0), - Effect.orElseSucceed(() => false), + Effect.map(({ response }): boolean | undefined => response === 0), + Effect.orElseSucceed(() => undefined), Effect.flatMap(settle), - Effect.onInterrupt(() => settle(false)), + Effect.onInterrupt(() => settle(undefined)), ), ); }; + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ + const confirmOpenExternal = ( + requester: WebContents | null, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const url = externalProtocolPromptUrl(rawUrl); + if (url === null) { + callback(false); + return; + } + promptPermission( + requester, + { + buttons: ["Open", "Cancel"], + message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, + detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`, + }, + (answer) => callback(answer === true), + ); + }; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( @@ -276,11 +283,36 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - // The guest keeps Electron's native User-Agent. Rewriting it in any - // form — even variants that keep the Electron token — makes Cloudflare - // Turnstile fail its integrity check with error 600010 and recreate - // the challenge every few seconds, so logins behind it never complete - // (#5002). + // Like Chrome, clipboard reads ask once per origin; both answers last for the session. + const clipboardReadDecisions = new Map(); + const confirmClipboardRead = ( + requester: WebContents, + rawUrl: string | undefined, + callback: (granted: boolean) => void, + ): void => { + const origin = permissionOrigin(rawUrl); + if (origin === null) { + callback(false); + return; + } + const remembered = clipboardReadDecisions.get(origin); + if (remembered !== undefined) { + callback(remembered); + return; + } + promptPermission( + requester, + { + buttons: ["Allow", "Block"], + message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`, + }, + (answer) => { + if (answer !== undefined) clipboardReadDecisions.set(origin, answer); + callback(answer === true); + }, + ); + }; + // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { @@ -291,11 +323,21 @@ export const make = Effect.gen(function* BrowserSessionMake() { ); return; } + if (permission === "clipboard-read") { + confirmClipboardRead( + webContents, + "requestingUrl" in details ? details.requestingUrl : undefined, + callback, + ); + return; + } callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }, ); - browserSession.setPermissionCheckHandler((_webContents, permission) => - ALLOWED_PREVIEW_PERMISSIONS.has(permission), + browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) => + permission === "clipboard-read" + ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true + : ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 2d17a28744545..1c1f9dc078ca0 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -65,8 +65,8 @@ describe("CDP relay", () => { ]); pending[0]!(); await settle(); - expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined); - expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session"); + expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined, undefined); + expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session", undefined); expect(written.at(-1)).toEqual({ id: 5, result: { method: "Page.captureScreenshot" }, @@ -92,7 +92,30 @@ describe("CDP relay", () => { }), ); await settle(); - expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + expect(send.mock.calls).toEqual([ + ["Runtime.runIfWaitingForDebugger", {}, "iframe-session", undefined], + ]); + }); + + // Playwright enables focus emulation on every page it connects to, and Chromium then + // counts the guest as captured: a preview the panel hides keeps its timers unthrottled. + it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { + const send = vi.fn(async () => ({})); + const written: Array> = []; + const relay = createCdpRelayConnection(makeTarget(send), (raw) => + written.push(JSON.parse(raw)), + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Emulation.setFocusEmulationEnabled", + params: { enabled: true }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(written).toEqual([{ id: 1, result: {}, sessionId: "t3-preview-page" }]); + expect(send).not.toHaveBeenCalled(); }); it("routes tab events to the page session once the page is announced", async () => { @@ -104,7 +127,6 @@ describe("CDP relay", () => { expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); await settle(); - // Electron passes an empty session id for the page's own events. relay.event("Page.loadEventFired", { timestamp: 1 }, ""); relay.event("Runtime.consoleAPICalled", {}, "iframe-session"); expect(written.slice(-2)).toEqual([ @@ -148,7 +170,7 @@ describe("CDP relay extra sessions", () => { expect(written.find((m) => m["id"] === 4)).toHaveProperty("error"); relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra })); await settle(); - expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined); + expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; relay.event("Page.screencastFrame", { data: "x" }, undefined); expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..948bc361f5d92 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,23 +1,20 @@ -/** - * Electron allows one debugger client per webContents, so the relay shares the - * attach the preview manager already holds. - */ +// Electron allows one debugger per webContents; share the manager's attachment. export interface CdpRelayTarget { readonly send: ( method: string, params: Record, sessionId: string | undefined, + /** The extra session that sent it, such as a viewer's; the main connection has none. */ + owner?: string, ) => Promise; + readonly sessionDetached?: (owner: string) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. - */ + /** The desktop and its server share a disk; download paths need no translation. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -56,11 +53,7 @@ export function createCdpRelayConnection( ): CdpRelayConnection { const send = (message: Record) => write(JSON.stringify(message)); let attached = false; - /** - * Sessions this relay invented. Extra page sessions (Playwright's - * `newCDPSession`) all reach the one real debugger session, so each gets the - * page's events; other ids are the page's own child sessions, such as iframes. - */ + /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { @@ -121,8 +114,10 @@ export function createCdpRelayConnection( } case "Target.detachFromTarget": { const sessionId = command.params?.["sessionId"]; - if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) + if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + target.sessionDetached?.(sessionId); + } return {}; } case "Target.setAutoAttach": { @@ -143,14 +138,15 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; - // The page itself is already running, but its child frames are not: - // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses - // every new cross-site iframe and worker until this reaches it. Swallowing - // it left sign-in checks and captchas, which run in such frames, frozen. + const owner = + child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; + // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } - return target.send(command.method, command.params ?? {}, child); + // Playwright asks for this on every page; the host emulates focus only while it is in use. + if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + return target.send(command.method, command.params ?? {}, child, owner); }; return { @@ -167,8 +163,7 @@ export function createCdpRelayConnection( ? browserCommand(command) : pageCommand(command); const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; - // Replies leave as commands finish, as Chromium's do, so a slow command - // such as a screenshot never holds up the ones behind it. + // Reply as commands finish so slow commands cannot block later replies. void result.then( (value) => { const announce = diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 50039c4e4b04e..08fefd8d733f5 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -30,18 +31,39 @@ const command = ( }), ); -const makeDebuggee = () => { +const makeDebuggee = ( + options: { + readonly deferFocus?: boolean; + readonly refuse?: (method: string, sessionId: string | undefined) => boolean; + } = {}, +) => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const focusChanges: Array<() => void> = []; const sent: Array = []; const sendWaiters: Array<() => void> = []; + const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string, params?: { id?: number }) => - new Promise((resolve) => { + sendCommand: ( + method: string, + params?: { id?: number; enabled?: boolean }, + sessionId?: string, + ) => + new Promise((resolve, reject) => { + if (options.refuse?.(method, sessionId)) { + reject(new Error("Refused")); + return; + } if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + if (method === "Emulation.setFocusEmulationEnabled") { + focusEmulation.push(params?.enabled === true); + if (options.deferFocus) focusChanges.push(() => resolve({})); + else resolve({}); + return; + } sent.push(params?.id); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); @@ -61,12 +83,18 @@ const makeDebuggee = () => { navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), releaseFirst: () => pending.shift()?.(), + settleFocus: () => focusChanges.splice(0).forEach((resolve) => resolve()), }; }; +/** Commands wait behind a focus emulation change; it settles within a macrotask. */ +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time +const settle = Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -93,6 +121,18 @@ const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => return replies; }); +/** Whether the client should paint the guest, each time that changes. */ +const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const painting: Array = []; + yield* host.painting.pipe( + Stream.runForEach(({ active }) => Effect.sync(() => painting.push(active))), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return painting; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -123,6 +163,7 @@ describe("DesktopBrowserHost", () => { yield* command(1, "Page.captureScreenshot"); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); + yield* settle; debuggee.release(); const [, reply] = yield* Fiber.join(reader); @@ -141,6 +182,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "DOM.enable"); + yield* settle; expect(debuggee.sent).toEqual([1, 3]); debuggee.releaseFirst(); @@ -159,6 +201,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; debuggee.navigate(); expect(debuggee.sent).toEqual([1, 2]); @@ -181,6 +224,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(host, 3, "Page.captureScreenshot"); expect(debuggee.sent).toEqual([1]); @@ -192,6 +236,682 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("emulates focus while the server drives a guest, then lets a hidden one throttle", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true]); + expect(debuggee.sent).toEqual([1]); + debuggee.release(); + yield* settle; + + yield* TestClock.adjust("4 seconds"); + yield* command(host, 2, "Input.dispatchMouseEvent"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("4 seconds"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* TestClock.adjust("10 seconds"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true, false, true]); + expect(debuggee.sent).toEqual([1, 2, 3]); + }), + ); + + it.effect("keeps a guest focused while a command it is running is still pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + // An evaluation awaiting the page's next frame, which a throttled guest never paints. + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A command the old document never answers. + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + debuggee.navigate(); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused for the screenshot a navigation lets start", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }), + ); + + it.effect("never sends a screenshot a navigation superseded while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + debuggee.settleFocus(); + yield* settle; + // Two captures in flight would undo the one-at-a-time guard. + expect(debuggee.sent).toEqual([2]); + }), + ); + + it.effect("keeps a guest focused when its turn starts after the agent's first command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + yield* turn(true); + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("lets a guest throttle through a turn that only sent it housekeeping", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // Playwright re-enabling a domain after a reload is not the agent using the tab. + yield* command(host, 1, "Runtime.enable"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 2, "Runtime.evaluate"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }), + ); + + it.effect("keeps a guest the agent used focused through its turn's pauses", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([]); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + debuggee.release(); + yield* settle; + // The model thinks between steps; a focus-sensitive menu must not blur meanwhile. + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect( + "ends a command's hold with the turn that sent it, even if Chromium never answers", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // A screenshot of a hidden guest the server already gave up on. + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: false }), + ); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("ends the agent's own session's holds with its turn, but not a viewer's stream", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server's own CDP session, evaluating a promise that never settles. + yield* turn(true); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Runtime.evaluate", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer streaming the guest keeps it focused through a turn's end. + yield* turn(true); + yield* raw({ id: 3, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(3); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }).pipe(Effect.scoped), + ); + + it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + expect(painting).toEqual([true, false]); + + // The agent's screenshot waits its turn; painting lets the abandoned one finish first. + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true, false, true]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.sent).toEqual([undefined, 4]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a queued screenshot once its session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The agent's screenshot is abandoned at turn end; a viewer's waits behind it, then leaves. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(painting).toEqual([true, false, true]); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(4); + expect(painting).toEqual([true, false, true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot whose session detached while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + expect(debuggee.sent).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The first answers late; the second still goes in turn, but holds nothing. + debuggee.releaseFirst(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ refuse: (method) => method === "Page.startScreencast" }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 3, method: "Input.dispatchMouseEvent", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a page's screencast when a child session's screencast is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ + refuse: (method, sessionId) => method === "Page.startScreencast" && sessionId !== undefined, + }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* command(host, 1, "Page.startScreencast"); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "child-frame" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a screencast that arrives on a session after it detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once the session waiting on its command detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // A viewer's own CDP session, closed while its still of a hidden guest is pending. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot queued by a session that has since detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* command(host, 2, "Page.captureScreenshot"); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + // The dropped screenshot's error reply leaves as the first frees its turn. + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([3, 2]); + yield* settle; + expect(debuggee.sent).toEqual([2]); + + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a streamed guest focused until its screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.startScreencast"); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* command(host, 2, "Page.stopScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer whose connection went away without stopping its screencast. + yield* command(host, 3, "Page.startScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused while any of its screencasts runs", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // Two viewers, or a viewer and a recording, each on its own session. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(painting).toEqual([]); + + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true]); + + // The first answers once the guest paints; the queued one then takes its turn. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2, 3]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot Chromium never answers", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server gave up on it, and the agent's turn ends. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The page loads a new document, which also frees the screenshot turn. + debuggee.navigate(); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + + // The server releases the relay. + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false]); + + // The guest detaches; a replacement starts with no screenshot pending. + const replacement = makeDebuggee(); + host.attach(key, replacement.tab); + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + host.detach(key); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a streamed guest until its last screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + expect(painting).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2d..30f252e3a677e 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. -/** - * The preview manager owns the tab's single debugger session and hands it here; - * the relay shares it. - */ +/** The manager owns the single debugger attachment; the relay shares it. */ import { DesktopBrowserCommand, DesktopBrowserEvent, @@ -25,7 +22,10 @@ const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBr const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** The same commands as above, by method, for the relay's sends. */ +const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const FOCUS_IDLE = "5 seconds"; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -44,10 +44,33 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - readonly waitingScreenshots: Array<() => void>; + /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + readonly waitingScreenshots: Array<{ + readonly start: () => void; + readonly live: () => boolean; + held: boolean; + }>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + focusEmulated: boolean; + focusChange: Promise | null; + focusUses: number; + /** Each pending command, by the extra session waiting on it; the main connection's have none. */ + readonly focusPending: Map< + object, + { readonly owner: string | undefined; readonly paints: boolean } + >; + /** Whether the client keeps the guest painting, as published on `painting`. */ + painting: boolean; + /** + * Driven by the agent since its thread's last turn ended or the guest last throttled. A + * turn's start can reach us after its first command, so this holds focus once it does. + */ + agentUsed: boolean; + focusReleasing: boolean; + /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ + readonly screencasts: Set; readonly onMessage: ( event: Electron.Event, method: string, @@ -72,6 +95,11 @@ export class DesktopBrowserHost extends Context.Service< readonly x: number; readonly y: number; }>; + /** Whether a guest needs its frames painted while the panel hides it. */ + readonly painting: Stream.Stream<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>; } >()("@t3tools/desktop/preview/DesktopBrowserHost") {} @@ -83,44 +111,192 @@ export const make = Effect.gen(function* () { readonly x: number; readonly y: number; }>(16); + const painting = yield* PubSub.unbounded<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>(); const runFork = Effect.runForkWith(yield* Effect.context()); const tabs = new Map(); + /** Threads whose agent turn runs, by the server's account. */ + const agentTurns = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { tab.screenshotTurn = null; - tab.waitingScreenshots.shift()?.(); + tab.waitingScreenshots.shift()?.start(); + releaseFocusWhenIdle(tab); + syncPainting(tab); }; /** * Chromium below 157 crashes the whole app when a guest's renderer exits with a * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that * far likelier, and one the server gave up on stays pending in Chromium, so each guest - * gets one at a time. One queued by a relay released since then is dropped unsent. + * gets one at a time. One whose relay or session has since gone is dropped unsent. */ const takeScreenshot = ( tab: AttachedTab, relay: CdpRelayConnection, - capture: () => Promise, + ownerLive: () => boolean, + /** Sends only while `current` holds; holds focus and painting only while `held`. */ + capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { - const start = () => { - if (tab.relay !== relay) { - reject(new Error("The preview connection that asked for this screenshot closed.")); - nextScreenshot(tab); + const live = () => tab.relay === relay && ownerLive(); + const waiting = { + start: () => { + if (!live()) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture(() => tab.screenshotTurn === turn && live(), waiting.held) + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }, + live, + held: true, + }; + if (tab.screenshotTurn === null) waiting.start(); + else { + // The one ahead may be abandoned mid-capture; it needs frames to finish and free its turn. + tab.waitingScreenshots.push(waiting); + setFocusEmulation(tab, true); + syncPainting(tab); + } + }); + + /** + * Chromium counts a focus-emulated guest as captured, so a guest the panel hides would + * never throttle. Without it, a hidden guest paints no frames and focus() fires no focus + * events, so the guest is emulated focused while the server drives it or streams it. + */ + const setFocusEmulation = (tab: AttachedTab, enabled: boolean) => { + if (tab.focusEmulated === enabled) return; + tab.focusEmulated = enabled; + // A guest that refuses it, or a debugger already gone, behaves as unfocused. + const change: Promise = tab.debuggee.debugger + .sendCommand("Emulation.setFocusEmulationEnabled", { enabled }) + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (tab.focusChange === change) tab.focusChange = null; + }); + tab.focusChange = change; + }; + + const waitingHolds = (tab: AttachedTab) => + tab.waitingScreenshots.some((waiting) => waiting.held && waiting.live()); + + const releaseFocusWhenIdle = (tab: AttachedTab) => { + if (tab.focusReleasing) return; + tab.focusReleasing = true; + runFork( + Effect.gen(function* () { + let uses: number; + do { + uses = tab.focusUses; + yield* Effect.sleep(FOCUS_IDLE); + } while (uses !== tab.focusUses); + tab.focusReleasing = false; + // The last pending command to settle starts another idle wait. + if (tab.focusPending.size > 0 || tab.screencasts.size > 0 || waitingHolds(tab)) { return; } - const turn = {}; - tab.screenshotTurn = turn; - capture() - .finally(() => { - if (tab.screenshotTurn === turn) nextScreenshot(tab); - }) - .then(resolve, reject); - }; - if (tab.screenshotTurn === null) start(); - else tab.waitingScreenshots.push(start); + // The agent pauses to think between steps; its tab stays focused until the turn ends. + if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return; + tab.agentUsed = false; + if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false); + }), + ); + }; + + /** + * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame + * that never comes. The client paints the guest behind the app while one is pending; the + * capture then finishes on its own, and the guest goes back offscreen once none is. + */ + const syncPainting = (tab: AttachedTab) => { + const active = + tabs.get(keyOf(tab.key)) === tab && + (tab.screencasts.size > 0 || + waitingHolds(tab) || + [...tab.focusPending.values()].some((hold) => hold.paints)); + if (tab.painting === active) return; + tab.painting = active; + runFork(PubSub.publish(painting, { key: tab.key, active })); + }; + + /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ + const holdFocus = ( + tab: AttachedTab, + method: string, + owner: string | undefined, + work: () => Promise, + ) => { + const pending = {}; + tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusUses += 1; + setFocusEmulation(tab, true); + releaseFocusWhenIdle(tab); + syncPainting(tab); + return work().finally(() => { + if (!tab.focusPending.delete(pending)) return; + tab.focusUses += 1; + releaseFocusWhenIdle(tab); + syncPainting(tab); }); + }; + + /** + * Chromium can leave a command unanswered, so the screenshot guard's release points + * also end every hold: a released relay has no one waiting, and a new document answers + * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + */ + const dropFocusHolds = (tab: AttachedTab) => { + tab.focusPending.clear(); + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + const dropRelay = (tab: AttachedTab) => { + tab.relay = null; + tab.screencasts.clear(); + tab.agentUsed = false; + dropFocusHolds(tab); + }; + + /** A detached session, such as a closed viewer, waits on nothing it sent. */ + const dropSessionHolds = (tab: AttachedTab, owner: string) => { + tab.screencasts.delete(owner); + for (const [pending, hold] of tab.focusPending) { + if (hold.owner === owner) tab.focusPending.delete(pending); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + /** + * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, + * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * session, so this cannot spare other sessions; a viewer's screencast still holds. + */ + const endAgentTurn = (threadId: string) => { + agentTurns.delete(threadId); + for (const tab of tabs.values()) { + if (tab.key.threadId !== threadId) continue; + tab.agentUsed = false; + // Still sent in turn, so the queue keeps moving, but no longer kept painting. + for (const waiting of tab.waitingScreenshots) waiting.held = false; + dropFocusHolds(tab); + } + }; const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; @@ -129,12 +305,57 @@ export const make = Effect.gen(function* () { sessionId === undefined ? sendPageCommand(webContents, debuggee, method, params) : debuggee.sendCommand(method, params, sessionId); + const detachedOwners = new Set(); + /** Viewers and recordings stream; the agent's own sessions never do. */ + const streamingOwners = new Set(); const relay: CdpRelayConnection = createCdpRelayConnection( { - send: (method, params, sessionId) => - method === "Page.captureScreenshot" - ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) - : send(method, params, sessionId), + send: (method, params, sessionId, owner) => { + // A child session's screencast is not the page's, such as a late one on a detached session. + if (sessionId === undefined && method === "Page.startScreencast") { + tab.screencasts.add(owner); + if (owner !== undefined) streamingOwners.add(owner); + } else if (sessionId === undefined && method === "Page.stopScreencast") { + tab.screencasts.delete(owner); + } + // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. + if ( + AGENT_INPUT_METHOD.test(method) && + (owner === undefined || !streamingOwners.has(owner)) + ) { + tab.agentUsed = true; + } + // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. + const forward = (current?: () => boolean) => { + const change = tab.focusChange; + const run = () => + current === undefined || current() + ? send(method, params, sessionId) + : Promise.reject(new Error("The page this screenshot was for has moved on.")); + return change === null ? run() : change.then(run); + }; + return method === "Page.captureScreenshot" + ? takeScreenshot( + tab, + relay, + () => owner === undefined || !detachedOwners.has(owner), + (current, held) => + held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + ) + : method === "Page.startScreencast" + ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + // A viewer can outlive a refused stream; nothing is streaming for it. + if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }) + : holdFocus(tab, method, owner, forward); + }, + sessionDetached: (owner) => { + detachedOwners.add(owner); + dropSessionHolds(tab, owner); + }, targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -156,10 +377,7 @@ export const make = Effect.gen(function* () { return relay; }; - /** - * Without a path Electron would open its Save dialog over the app for a file - * the agent asked for. CDP names the download just before this runs. - */ + // CDP supplies the path before this runs; otherwise Electron opens a Save dialog. const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, @@ -178,6 +396,7 @@ export const make = Effect.gen(function* () { tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); + syncPainting(tab); emit({ type: "detached", ...key }); }; @@ -194,6 +413,14 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + focusEmulated: false, + focusChange: null, + focusUses: 0, + focusPending: new Map(), + painting: false, + agentUsed: false, + focusReleasing: false, + screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -204,6 +431,8 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { + // Before the next screenshot starts, so its own hold survives. + dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, }; @@ -217,6 +446,11 @@ export const make = Effect.gen(function* () { Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; + if (command.value.type === "turn") { + if (command.value.active) agentTurns.add(command.value.threadId); + else endAgentTurn(command.value.threadId); + return; + } const tab = tabs.get(keyOf(command.value)); if (!tab) return; if (command.value.type === "pointer") { @@ -225,7 +459,7 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - tab.relay = null; + dropRelay(tab); return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); @@ -237,19 +471,22 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - const announceAll = Effect.suspend(() => - Effect.forEach( + const announceAll = Effect.suspend(() => { + // A new backend: the old one's turns ended without telling us. + agentTurns.clear(); + return Effect.forEach( [...tabs.values()], (tab) => { - tab.relay = null; + dropRelay(tab); return PubSub.publish(outbox, { type: "attached", ...tab.key }); }, { discard: true }, - ), - ); + ); + }); return DesktopBrowserHost.of({ pointers: Stream.fromPubSub(pointers), + painting: Stream.fromPubSub(painting), // Subscribes before announcing, so no attach falls between the two. events: Stream.unwrap( Effect.gen(function* () { diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index a2b36d182a8f3..4716645c9defa 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -669,8 +669,7 @@ async function rasterizeFavicon( ); if (signal.aborted) onAbort(); }); - // The logical timeout does not cancel Electron's renderer work. Keep the - // gate closed until that physical execution actually settles. + // Logical timeouts leave renderer work running; keep the gate closed until it settles. const launchAllowed = execution.then( () => undefined, () => undefined, diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 6cb297ff81e90..b531182c92959 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; -/** A guest whose page reports `focusedDocument` for the focus probe. */ const makeGuest = ({ focused, focusedDocument = true, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 35006dfcec08b..42201b48ed61c 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,26 +1,12 @@ -/** - * Keyboard and text input for a preview tab's page that never lands outside it. - * - * Chromium sends a page's CDP key and text input to whichever frame holds focus - * in the window. A `` guest shares that focus with the app around it, - * so while a person works in the app, `Input.insertText` on the guest's own - * debugger types into the app's focused field. Such input goes through CDP only - * while the guest holds focus. Otherwise it goes straight to the guest's own - * widget, which nothing redirects. - */ +// CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. -/** CDP input Chromium delivers to the window's focused frame rather than the page's. */ const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition", ]); -/** - * Page script: the document holding the page's focus, or null when a cross-site - * frame holds it. Electron's direct input reaches only the main frame's process, - * and `insertText` crashes the page when focus is in another one. - */ +/** Direct input reaches only the main-frame process; insertText crashes when a cross-site frame has focus. */ const FOCUSED_DOCUMENT = `(() => { let doc = document; for (;;) { @@ -31,11 +17,7 @@ const FOCUSED_DOCUMENT = `(() => { } })()`; -/** - * macOS editing commands a key press asks for (Cmd+A is `selectAll`), which - * Chromium runs only for CDP keys. Clipboard commands stay out: they would read - * or replace the person's clipboard. - */ +/** Electron runs editing commands only for CDP keys; exclude clipboard commands to preserve the user's clipboard. */ const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); const CROSS_SITE_FOCUS_MESSAGE = @@ -74,18 +56,13 @@ const MODIFIER_BITS = [ [8, "shift"], ] as const; -/** Electron's accelerator name for a DOM key value, or null when it has none. */ const keyCodeFor = (key: string) => { if ([...key].length === 1) return key; if (ARROW_KEYS[key]) return ARROW_KEYS[key]; return NAMED_KEYS.has(key) || /^F(?:[1-9]|1\d|2[0-4])$/u.test(key) ? key : null; }; -/** - * The Electron key events for one CDP `Input.dispatchKeyEvent`. Electron - * derives key, code, and a char's text from `keyCode`, so typed text travels - * as `char` events of its own. - */ +/** Electron derives text from keyCode, so typed text needs separate char events. */ export const toKeyboardInputEvents = ( params: Record, ): Array => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1ba..f19079e46bce4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -80,8 +80,6 @@ describe("previewWindowOpenAction", () => { }); it("opens a real window for scripted popups so the opener survives", () => { - // OAuth SDKs read a null `window.open()` as a blocked popup, and they need - // the opener alive to receive the credential back. expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); expect( PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), @@ -89,7 +87,6 @@ describe("previewWindowOpenAction", () => { }); it("keeps a form POST with a body on the in-place path", () => { - // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( PreviewManager.previewWindowOpenAction({ @@ -100,9 +97,6 @@ describe("previewWindowOpenAction", () => { }); it("does not hand a window to schemes that cannot be hardened", () => { - // A popup skips the `will-attach-webview` hardening, so it only gets a window - // when its preferences can be overridden. Chromium copies the guest's - // preferences for `about:blank` and forbids overriding them. for (const url of [ "javascript:alert(1)", "file:///etc/passwd", @@ -114,8 +108,6 @@ describe("previewWindowOpenAction", () => { }); it("denies a blank window instead of blanking the opener", () => { - // MSAL opens `about:blank` first and falls back to a redirect on `null`; - // loading the blank page into the tab would destroy that fallback. for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); @@ -155,6 +147,7 @@ const { vi.mock("electron", () => ({ BrowserWindow: browserWindowConstructor, + // oxlint-disable-next-line typescript/no-extraneous-class -- This constructor-only mock implements the browser ClipboardItem API. ClipboardItem: class { constructor(data: Record) { clipboardItemConstructor(data); @@ -249,7 +242,6 @@ const withManager = ( interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; - /** Only fixtures that deliver picture-in-picture frames need pixels to fingerprint. */ readonly toBitmap?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } @@ -608,7 +600,6 @@ describe("PreviewManager", () => { const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); - // Electron names one arbitrary guest here when several share a focused window. getFocusedWebContents.mockReturnValue(newer.webContents as never); fromId.mockReturnValue(older.webContents); yield* manager.createTab("tab_older"); @@ -634,7 +625,6 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); - // The person types in the older tab, so keys reaching the newer one are an agent's. Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -770,9 +760,6 @@ describe("PreviewManager", () => { effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { - // Real Electron throws on any `wc.debugger` access once the - // WebContents is destroyed, so cleanup must go through the debugger - // reference captured at attach time (electron/electron#53376). let destroyed = false; let attached = false; const debuggerOff = vi.fn(); @@ -1061,7 +1048,6 @@ describe("PreviewManager", () => { id, isDestroyed: () => destroyed, isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. if (destroyed) throw new TypeError("Object has been destroyed"); return false; }, @@ -1173,11 +1159,7 @@ describe("PreviewManager", () => { audible = next; listeners.get("audio-state-changed")?.({ audible: next } as never); }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ + /** Simulate playback starting during attach before the tab owns the guest and can receive its events. */ startPlayingAfterFirstRead: () => { audibleAfterFirstRead = true; }, @@ -1237,9 +1219,6 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. expect(states.at(-1)?.audible).toBe(true); }), ), @@ -1369,7 +1348,6 @@ describe("PreviewManager", () => { fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. const first = yield* manager.captureScreenshot("tab_1"); const second = yield* manager.captureScreenshot("tab_1"); @@ -1735,6 +1713,65 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); + effectIt.effect( + "asks the client to paint a navigated server tab while the agent captures it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + loadURL: vi.fn(async () => undefined), + }, + ); + const screenshot = Promise.withResolvers(); + contents.debugger.sendCommand = vi.fn((method: string) => + method === "Page.captureScreenshot" ? screenshot.promise : Promise.resolve(undefined), + ) as unknown as typeof contents.debugger.sendCommand; + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const capturing = yield* Queue.unbounded(); + yield* manager.subscribeStateChanges((_tabId, state) => + Queue.offer(capturing, state.capturing).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + // Navigating rebuilds the tab's state, which must keep the server tab it answers for. + yield* manager.navigate("tab_1", "https://example.com/next"); + yield* Queue.clear(capturing); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Page.captureScreenshot", + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* Queue.take(capturing)).toBe(true); + screenshot.resolve({ data: "" }); + expect(yield* Queue.take(capturing)).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae40..036c0d4afd932 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,3 @@ -/** - * Desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ import * as NodeZlib from "node:zlib"; import { DesktopPreviewRecordingInputSchema, @@ -109,6 +102,7 @@ export interface PreviewTabState { audioMuted: boolean; audible: boolean; controller: "human" | "agent" | "none"; + capturing: boolean; favicon?: DesktopPreviewFavicon; serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; @@ -123,10 +117,7 @@ const ZOOM_EPSILON = 0.001; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; -/** - * Cold guests can reject capturePage with UnknownVizError or never settle it. - * Bound each attempt so snapshots release control even when Chromium stalls. - */ +// Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; @@ -325,22 +316,12 @@ const cropAnnotationScreenshot = ( return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; -/** - * Electron converts `toBitmap()` output to sRGB by default. Captures on P3 Macs are - * P3, so that conversion costs more per tick than the JPEG encode the fingerprint - * replaces. An invalid target color space has no Skia color space, so `readPixels` - * copies the pixels without a color transform. The fingerprint only compares frames - * with each other, so color accuracy is irrelevant. - */ +// An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; -/** - * Identity of a captured frame: cheap enough to compute every tick, unlike a JPEG - * encode. Null when the pixels could not be read (Electron reports a failed - * `readPixels` as an empty buffer), so the caller treats the frame as changed. - */ +/** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { @@ -378,7 +359,6 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { }; type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -421,10 +401,7 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Electron's Debugger is GC-managed but registered with Chromium as a raw - // DevToolsAgentHostClient pointer; collecting it while attached crashes the - // browser process (electron/electron#53376). Detach must also go through - // this reference: `wc.debugger` throws once the WebContents is destroyed. + // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; @@ -443,13 +420,7 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; -/** - * `about:blank` stays out: Chromium skips browser-side navigation for it, so the child copies - * the guest's `contextIsolation: false` preferences and Electron gives no way to override them. - * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits - * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a - * window spawned by a third-party page in the preview. - */ +// about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { @@ -460,12 +431,7 @@ const isPopupUrl = (rawUrl: string): boolean => { } }; -/** - * A popup is not a webview attach, so the `will-attach-webview` hardening in `DesktopWindow` - * never sees it, and an unoverridden child would inherit the guest's relaxed posture: the - * picker preload needs `contextIsolation: false` to share `globalThis` with the previewed page, - * and no OAuth provider should get that. - */ +/** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, @@ -474,18 +440,13 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** - * `"popup"` opens a real window, which scripted popups need: denying them makes `window.open()` - * return `null` (OAuth SDKs report that as a blocked popup), and navigating the preview tab - * instead destroys the opener the popup has to `postMessage` its result back to. Schemes a - * popup cannot be hardened for keep loading in place, and so does a form POST with a body, - * which a new tab could only reopen as a GET. - */ +/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. if (details.url === "" || details.url === "about:blank") return "deny"; if (!isPopupUrl(details.url)) return "navigate"; if (details.disposition === "new-window") return "popup"; @@ -574,9 +535,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const recordingInputListenersRef = yield* Ref.make>( new Set(), ); - const recordingFrameListenersRef = yield* Ref.make>( - new Set(), - ); const pickSessionsRef = yield* Ref.make>(new Map()); const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap @@ -590,9 +548,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); - // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy - // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream - // (electron#44618) and now always rejects with NotAllowedError. + // Legacy tab capture is unsupported (electron/electron#44618); recording uses setDisplayMediaRequestHandler. let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; @@ -841,12 +797,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: - | "state-change" - | "recording-frame" - | "recording-input" - | "pointer-event" - | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", tabId: string, delivery: () => Effect.Effect, ) => @@ -897,10 +848,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible - // can commit between the modify above and here, and republishing this - // snapshot would roll the UI back to a value that writer will not send - // again because it suppresses unchanged audibility. + // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { if (patch.controller !== undefined && next.value.webContentsId != null) { const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); @@ -917,11 +865,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - /** - * Anything that applies zoom after an await goes through here: a snapshot taken before the - * await can be older than a zoom action that landed in between, and re-applying it would roll - * that action back. - */ + /** Read zoom after awaits so concurrent zoom actions cannot be rolled back by an older snapshot. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; @@ -932,11 +876,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.ignore); }); - /** - * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute - * onto whichever guest it currently owns, reading both at call time so an - * older snapshot can never roll back a mute action that landed after it. - */ + /** Read committed mute and current guest at call time so stale snapshots cannot roll back newer actions. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, ) { @@ -1098,8 +1038,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); - // Console message eviction does not release the debugger's strong object handles. We - // keep text only, so release the whole group, including its object-id bookkeeping. + // Console eviction leaves strong debugger handles; release the group because we keep only text. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1129,35 +1068,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } - const tabId = yield* tabIdForWebContents(wc.id); - const metadata = - typeof params["metadata"] === "object" && params["metadata"] !== null - ? (params["metadata"] as Record) - : {}; - if (tabId && typeof params["data"] === "string") { - const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get( - tabId, - ); - if (captureSession?.consumers.has("recording")) { - const receivedAt = yield* currentIso; - const listeners = yield* Ref.get(recordingFrameListenersRef); - const frame: DesktopPreviewRecordingFrame = { - tabId, - data: params["data"], - width: - typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0, - height: - typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0, - receivedAt, - }; - yield* Effect.forEach( - listeners, - (listener) => - deliverEvent("recording-frame", frame.tabId, () => listener(frame)), - { discard: true }, - ); - } - } } if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); @@ -1198,9 +1108,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); - // Without this, dark-scheme pages with no background of their own (text/plain, e.g. - // .md files) render white text on white. Sent first because a document that paints - // before it arrives keeps the transparent base until its next load. + // Set an opaque base before first paint or background-less dark pages retain white-on-white text. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1250,9 +1158,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - // Main frame only. `isLoading()` covers the whole frame tree, so a - // cross-origin iframe that loads after the page can leave it true with no - // later event to clear it, and the tab's loading bar never finishes. + // isLoading() includes late iframes whose completion may never clear the tab's loading bar. if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1291,9 +1197,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { return [Option.none(), tabs] as const; } - // Electron emits did-stop-loading after did-fail-load. At that point the - // failed guest is no longer "loading", but it has not successfully - // navigated anywhere. + // did-stop-loading follows did-fail-load without proving successful navigation. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1311,9 +1215,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - // zoomFactor is deliberately not read back from the guest: Chromium - // reports the level it inherited from the app window, so mirroring it - // would turn an app zoom into the preview's own zoom. + // Guest zoom inherits app zoom; mirroring it would overwrite the tab's own setting. updatedAt, }; return [ @@ -1517,8 +1419,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - // Not getFocusedWebContents(): every guest of a focused window reports focus. - // An agent's keys reach a guest without a focused frame through GuestInput. + // Every guest of a focused window reports focus; GuestInput handles guests without a focused frame. const holdsKeyboardFocus = (contents: Electron.WebContents) => contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { @@ -1527,9 +1428,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; - // A popup opens with Electron's default handler, so the page inside it could - // otherwise spawn native windows without limit. Nothing in an OAuth flow - // opens a second popup, so the chain stops at the first one. + // Popups inherit Electron's unrestricted window handler; block nested popups to bound the chain. const windowCreated = (window: Electron.BrowserWindow): void => { window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); @@ -1568,7 +1467,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); if (forwarded && host) { event.preventDefault(); - if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (!input.isAutoRepeat) { + if ( + forwarded.command === "workspace.focusNext" || + forwarded.command === "workspace.focusPrevious" + ) { + // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. + host.focus(); + } + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } return; } if (isPreviewRefreshShortcut(input)) { @@ -1716,6 +1624,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; return [ @@ -1789,6 +1698,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; yield* emit(tabId, closed); @@ -1872,15 +1782,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Always assert the tab's own zoom rather than reading the guest's: a guest - // attaching while the app UI is zoomed starts at the embedder's inherited - // zoom level, which is not the preview's zoom. Done before the guest is - // published so it never paints a frame at the inherited zoom. + // Restore the tab's zoom before publishing the guest so it never paints at inherited app zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); - // A replacement guest attaches unmuted, so reassert the tab's mute before it is published - // rather than letting it emit audio the user already silenced. + // Replacement guests start unmuted; restore mute before publishing them. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1934,16 +1840,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; - // A zoom or mute action that landed while this attach was in flight - // addressed the guest this one replaced, so settle the new guest on the - // committed values. + // Concurrent actions targeted the previous guest; reconcile this one with committed zoom and mute. yield* assertTabZoom(tabId); yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); yield* emitIfCurrent(tabId, registered); - // Transitions that fired before the tab owned this guest were dropped by - // syncTabAudible's ownership check, so re-read and reconcile through the - // same path the event uses. + // Ownership checks dropped pre-attach audibility events; reconcile through the event path. yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1975,9 +1877,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document paints. A tab opened - // straight to a URL often paints before the renderer gets to registerWebview, which would - // leave that page on the transparent base. + // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { @@ -2016,14 +1916,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Mute is user intent, and the old document keeps playing until loadURL - // actually replaces it, so clearing audibility here would drop the - // speaker with no transition left to restore it. Chromium reports the - // change when it happens. + // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", + capturing: current?.capturing ?? false, ...(current?.favicon ? { favicon: current.favicon } : {}), + ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ @@ -2177,8 +2076,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); - // Only drop the slot while it is still ours. A newer session may - // already have swapped itself in before cancelling this one. + // A newer session may already own the slot when this one is cancelled. yield* Ref.update(pickSessionsRef, (sessions) => sessions.get(tabId) === session ? replaceMap(sessions, (copy) => { @@ -2270,9 +2168,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : { annotation: { ...payload, screenshot }, submission }, }), Effect.flatMap((result) => { - // A capture that outlives its session must not touch the - // overlay: the preload tears down on the captured signal, and - // by now it may be running a newer pick. + // Late captures must not signal an overlay that may now belong to a newer pick. if (!claimSettle()) return Effect.void; return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, @@ -2291,10 +2187,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - // Two picks on one tab can overlap. Swap this session in and cancel - // the previous holder in one step, so no third pick can slip into an - // empty slot in between and the session we push out still resumes - // its renderer. + // Atomically swap/cancel overlapping picks so another pick cannot claim a transient empty slot. const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ sessions.get(tabId) ?? null, replaceMap(sessions, (copy) => { @@ -2302,9 +2195,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (replaced) yield* replaced.cancel; - // A newer pick may have cancelled this session while the previous - // one was torn down. Cleanup already ran, so attaching listeners now - // would leak them and start an overlay nobody is waiting on. + // A newer pick may cancel during teardown; do not leak listeners or start an abandoned overlay. if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); @@ -2352,9 +2243,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); - // Emulated media lives on the CDP debugger session, not the WebContents, so - // it is lost whenever the session detaches (webview swap, DevTools - // open/close) and must be re-applied after every (re)attach. + // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, @@ -2386,7 +2275,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (afterAttach.serverTab) { - yield* listenForAgentPointers; + yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); } if (afterAttach.colorScheme !== "system") { @@ -2440,8 +2329,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.colorScheme !== colorScheme) { yield* update(tabId, { colorScheme }); } - // Re-read after the update: registerWebview may have swapped the guest - // in the meantime and the override must land on the current one. + // registerWebview may swap guests during the update; apply to the current one. const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; if (webContentsId == null) return; const wc = webContents.fromId(webContentsId); @@ -2457,10 +2345,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Commit and apply under the tab's lifecycle lock, then assert the - // committed value rather than this call's argument. Two overlapping toggles - // would otherwise be free to commit in one order and reach Chromium in the - // other, leaving the icon disagreeing with the guest. + // Serialize commit/apply, then read committed mute so concurrent toggles cannot reach Chromium out of order. yield* withTabLifecycleLock( tabId, Effect.gen(function* () { @@ -2469,8 +2354,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (committed) { yield* update(tabId, { audioMuted }); } - // Roll the commit back if Chromium refused: reporting success here - // would leave the tab drawn as muted while it keeps playing. + // Roll back rejected mute commits so the UI reflects actual playback. yield* assertTabAudioMuted(tabId).pipe( Effect.tapError(() => committed ? update(tabId, { audioMuted: previous }) : Effect.void, @@ -2582,9 +2466,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (frameSession?.scope !== captureSession.scope) return; - // Fingerprint the raw pixels before encoding so an unchanged page skips the - // synchronous JPEG encode, the base64 copy and the IPC send. An unreadable - // frame has no fingerprint and is always delivered. + // Fingerprint before encoding so unchanged frames skip JPEG, base64, and IPC; unreadable frames must still be sent. const fingerprint = fingerprintCapturedFrame(image, size); const pictureInPicture = frameSession.consumers.has("picture-in-picture") && @@ -2606,78 +2488,66 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function height: size.height, receivedAt, }; - const deliveries: Array> = []; - if (pictureInPicture) { - const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( - tabId, - )?.window; - if (pictureInPictureWindow && !pictureInPictureWindow.isDestroyed()) { - deliveries.push( - Effect.gen(function* () { - const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get( - tabId, - ); - const aspectRatio = frame.width / frame.height; - if ( - previousAspectRatio === undefined || - Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON - ) { - yield* attempt( - { - operation: "pictureInPicture.setAspectRatio", - tabId, - webContentsId: wc.id, - }, - () => { - const contentSize = fitPictureInPictureContentSize( - pictureInPictureWindow.getContentSize(), - aspectRatio, - ); - pictureInPictureWindow.setAspectRatio(0); - pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); - pictureInPictureWindow.setAspectRatio(aspectRatio); - }, - ); - yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => - replaceMap(aspectRatios, (copy) => { - copy.set(tabId, aspectRatio); - }), - ); - } - yield* attempt( - { - operation: "pictureInPicture.deliverFrame", - tabId, - webContentsId: wc.id, - }, - () => { - pictureInPictureWindow.webContents.send( - PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, - frame, - ); - }, + const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get( + tabId, + )?.window; + if (!pictureInPictureWindow || pictureInPictureWindow.isDestroyed()) return; + yield* Effect.gen(function* () { + const previousAspectRatio = (yield* Ref.get(pictureInPictureAspectRatiosRef)).get(tabId); + const aspectRatio = frame.width / frame.height; + if ( + previousAspectRatio === undefined || + Math.abs(previousAspectRatio - aspectRatio) > PICTURE_IN_PICTURE_ASPECT_RATIO_EPSILON + ) { + yield* attempt( + { + operation: "pictureInPicture.setAspectRatio", + tabId, + webContentsId: wc.id, + }, + () => { + const contentSize = fitPictureInPictureContentSize( + pictureInPictureWindow.getContentSize(), + aspectRatio, ); - yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { - if (sessions.get(tabId) !== frameSession) return sessions; - return replaceMap(sessions, (copy) => { - copy.set(tabId, { - ...frameSession, - lastPictureInPictureFingerprint: fingerprint, - }); - }); - }); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Picture-in-picture frame delivery failed.", { - tabId, - error, - }), - ), - ), + pictureInPictureWindow.setAspectRatio(0); + pictureInPictureWindow.setContentSize(contentSize[0], contentSize[1], false); + pictureInPictureWindow.setAspectRatio(aspectRatio); + }, + ); + yield* Ref.update(pictureInPictureAspectRatiosRef, (aspectRatios) => + replaceMap(aspectRatios, (copy) => { + copy.set(tabId, aspectRatio); + }), ); } - } - yield* Effect.all(deliveries, { concurrency: 2, discard: true }); + yield* attempt( + { + operation: "pictureInPicture.deliverFrame", + tabId, + webContentsId: wc.id, + }, + () => { + pictureInPictureWindow.webContents.send(PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, frame); + }, + ); + yield* SynchronizedRef.update(frameCaptureSessionsRef, (sessions) => { + if (sessions.get(tabId) !== frameSession) return sessions; + return replaceMap(sessions, (copy) => { + copy.set(tabId, { + ...frameSession, + lastPictureInPictureFingerprint: fingerprint, + }); + }); + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Picture-in-picture frame delivery failed.", { + tabId, + error, + }), + ), + ); }); const startFrameCapture = Effect.fn("PreviewManager.startFrameCapture")(function* ( @@ -2939,8 +2809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (hostPlatform === "darwin") { pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, - // Electron otherwise temporarily transforms the entire app into - // a UIElement process, which removes the owning app from the Dock. + // Electron's temporary UIElement transformation would remove the app from the Dock. skipTransformProcessType: true, }); } @@ -3075,10 +2944,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (pendingRecording?.tabId === tabId) pendingRecording = null; }; - /** - * A display-media request carries no tab identity, so the slot is exclusive: a second tab - * arming before the first request lands would redirect the first renderer's stream. - */ + /** Display-media requests lack tab identity; only one tab may arm a recording to prevent stream redirection. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, wc: Electron.WebContents, @@ -3105,8 +2971,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function armedAtMillis: now, }; pendingRecording = armed; - // The handler callback is sync and cannot read a clock, so expiry is driven from here. - // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + // The synchronous callback cannot read a clock. Compare identity so expiry cannot clobber a re-arm. yield* Effect.forkIn( Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( Effect.andThen( @@ -3119,8 +2984,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that - // `startRecording` armed, and denies anything else so pages cannot capture on their own. + // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); @@ -3339,14 +3203,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - let pointersStarted = false; - const listenForAgentPointers = Effect.suspend(() => { - if (pointersStarted) return Effect.void; - pointersStarted = true; - return browserHost.pointers.pipe( - Stream.runForEach(emitAgentPointer), - Effect.forkIn(parentScope), - Effect.asVoid, + const updateServerTabCapturing = Effect.fn("PreviewManager.updateServerTabCapturing")( + function* (painting: { + readonly key: { readonly threadId: string; readonly tabId: string }; + readonly active: boolean; + }) { + const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( + (candidate) => + candidate.serverTab?.threadId === painting.key.threadId && + candidate.serverTab.tabId === painting.key.tabId, + ); + if (tab && tab.capturing !== painting.active) { + yield* update(tab.tabId, { capturing: painting.active }); + } + }, + ); + let browserHostStarted = false; + // Subscribes before the attach that follows, so no event falls between the two. + const listenToBrowserHost = Effect.suspend(() => { + if (browserHostStarted) return Effect.void; + browserHostStarted = true; + const listen = Effect.forkIn(parentScope, { startImmediately: true }); + return Effect.all( + [ + browserHost.pointers.pipe(Stream.runForEach(emitAgentPointer), listen), + browserHost.painting.pipe(Stream.runForEach(updateServerTabCapturing), listen), + ], + { discard: true }, ); }); @@ -3382,7 +3265,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), - Ref.set(recordingFrameListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3430,8 +3312,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), - subscribeRecordingFrames: (listener: RecordingFrameListener) => - subscribe(recordingFrameListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), zoomIn: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "in")), zoomOut: (tabId: string) => applyZoom(tabId, (current) => nextZoomLevel(current, "out")), @@ -3673,13 +3553,10 @@ export class PreviewManager extends Context.Service< readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; - readonly subscribeRecordingFrames: ( - listener: RecordingFrameListener, - ) => Effect.Effect; } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; @@ -3809,7 +3686,6 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, - subscribeRecordingFrames: operations.subscribeRecordingFrames, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index ccdffa2a70c08..91cefaddc42f1 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -6,8 +6,7 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; const ES256 = -7; -// WebAuthn rejects RP IDs that are public suffixes, private registries -// included, so one github.io site cannot mint passkeys for every other one. +// WebAuthn rejects public suffix RP IDs, including private registries such as github.io. const isPublicSuffix = (domain: string) => domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; @@ -32,13 +31,10 @@ const toBase64Url = (value: BufferSource) => : new Uint8Array(value), ).toString("base64url"); -/** - * Needs Apple's managed browser entitlement in the signed build. - */ +/** Requires Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { - // The native layer only converts P-256 keys and never settles for any - // other algorithm, so ES256 is the only one it may negotiate. + // The native layer only converts P-256 keys, so negotiate only ES256. const params: unknown = publicKey.pubKeyCredParams; if (params !== undefined && !Array.isArray(params)) return { success: false, error: "TypeError" }; @@ -56,8 +52,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer reports parsed authenticator data as JSON and claims - // every credential is a synced platform passkey; neither is reliable. + // Native parsed authenticator JSON and synced-platform-passkey claims are unreliable. const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); @@ -79,8 +74,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer applies the site's allow list to platform passkeys only, - // so a security key can answer with a credential the site did not ask for. + // Native allow lists cover only platform passkeys; security-key responses need checking here. const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) .map((credential) => toBase64Url(credential.id)); diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3b72ab5890757..998a9298baf3c 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -27,10 +27,6 @@ const illegalInvocation = (): never => { throw new TypeError("Illegal invocation"); }; -/** - * Installs the bridge over fresh stand-ins for the page's DOM classes. Like - * Chromium's, their getters only work on objects the browser created itself. - */ const install = (respond: (channel: string) => unknown) => { const nativeCreate = vi.fn(async () => "native-create"); const nativeGet = vi.fn(async () => "native-get"); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index fbb9fefa4aa5f..bc77b38c1cf0a 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -44,10 +44,7 @@ const ceremonyError = (name: string) => DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", ); -/** - * Chromium ignores members it does not know, so functions and other uncloneable - * values drop out here instead of failing the whole ceremony. - */ +// Chromium drops unknown members; discard uncloneable values instead of failing the ceremony. const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) { @@ -64,9 +61,7 @@ const toCloneable = (value: unknown): unknown => { return value; }; -// The objects below borrow the native prototypes so `instanceof` checks pass. -// Their own properties shadow the native getters, which only work on objects -// Chromium created itself. +// Shadow native getters: they reject objects Chromium did not create, even with the correct prototype. const withValues = (target: T, values: Record) => { for (const [key, value] of Object.entries(values)) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); @@ -202,10 +197,7 @@ const credentialFromGetResult = (data: Extract signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); -/** - * Conditional (autofill and automatic upgrade) requests stay native: the system - * sheet is modal and must not open on its own. - */ +/** Keep conditional requests native so autofill and upgrades cannot open a modal sheet unprompted. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index f848aff47b8f9..219c67fac5061 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -120,7 +120,6 @@ describe("PreviewPasskeys", () => { assert.isFalse(options.isPublicSuffix("example.com")); assert.isFalse(options.isPublicSuffix("localhost")); - // WebAuthn defaults the RP ID to the caller's own host. yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); @@ -218,7 +217,6 @@ describe("PreviewPasskeys", () => { allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], }; - // macOS applies the allow list to passkeys but not to security keys. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); @@ -244,7 +242,6 @@ describe("PreviewPasskeys", () => { const lan = makeGuest("http://192.168.1.5:3000"); passkeys.attachGuest(lan.guest); assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // macOS refuses `*.localhost`, so the page hears no rather than a native error. const subdomain = makeGuest("http://app.localhost:3000"); passkeys.attachGuest(subdomain.guest); assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index 5456aaab6a8a2..34db1404aacb8 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -84,8 +84,7 @@ const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; -// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony -// unsettled, and the page must still get an answer. +// Native ceremonies can remain unsettled; match its deadline clamp with a margin. const ceremonyDeadline = (timeout: unknown) => (typeof timeout === "number" && timeout > 0 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) @@ -99,9 +98,7 @@ const withDeadline = (ceremony: Promise, milliseconds: nu return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); }; -// Secure contexts only: https, or http on this machine. Chromium also trusts -// `*.localhost`, but the macOS layer refuses it, so it is turned away here -// instead of failing deeper with a less useful error. +// macOS refuses *.localhost even though Chromium trusts it. const isTrustworthyOrigin = (origin: string) => { if (!URL.canParse(origin)) return false; const { protocol, hostname } = new URL(origin); @@ -112,9 +109,7 @@ const isTrustworthyOrigin = (origin: string) => { ); }; -/** - * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. - */ +// Chromium already delegates to Windows Hello. const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -168,9 +163,7 @@ const make = Effect.gen(function* () { // Only the guest's main frame runs the bridge preload. const frame = event.senderFrame; if (!frame || frame !== guest.mainFrame) return notAllowed; - // The page shares a JS world with the preload, so nothing it sends can - // be trusted for the origin. Read the committed origin before any await: - // the frame object outlives a navigation. + // The page shares the preload's JS world; read the frame's committed origin before any await/navigation. const origin = frame.origin; if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4059468da22e9..4cb5987cdeb85 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -197,11 +197,7 @@ const navigationDirectionForButton = (button: number): "back" | "forward" | null return null; }; -// Chromium routes thumb-button history navigation to the *focused* WebContents, -// so hovering this guest without focusing it sends the host app's router back -// instead of the preview. Suppress Chromium's default here and drive this tab's -// history explicitly so the buttons always navigate the browser the pointer is -// over — never the host app. +// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. const suppressNavigationButton = (event: MouseEvent): void => { if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; event.preventDefault(); @@ -391,11 +387,7 @@ function toStackFrame(frame: { }; } -/** - * `getElementContext` walks the inspected page's React internals, and some pages leave it - * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on - * "Capturing…". - */ +/** React-internals lookups can remain pending forever; bound them so submit cannot stall the overlay. */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; return Promise.race([ @@ -408,11 +400,7 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise { const base = { pageUrl: location.href, @@ -1399,8 +1387,7 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; - // The element context lookup can take up to its timeout, and the user can - // keep editing meanwhile; the annotation must describe what they submitted. + // Snapshot the submitted comment before context lookup lets the user edit it. const submittedComment = comment.value.trim(); const submittedRegions = [...regions]; const submittedStrokes = [...strokes]; @@ -1421,8 +1408,7 @@ function startAnnotation(sendEnabled: boolean): void { }), ) .then((elements) => { - // The overlay may have been cancelled or replaced while the capture - // ran. A late submit must not deliver into the next pick's listener. + // A late submit must not reach a replacement pick's listener. if (finished) return; const annotation: PreviewAnnotationPayload = { id: nextId("annotation"), @@ -1461,8 +1447,7 @@ function startAnnotation(sendEnabled: boolean): void { root.addEventListener("keydown", (event) => { const submission = event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Keep this in the bubble phase so editor inputs receive the event before - // it is isolated from listeners installed by the inspected page. + // Bubble phase lets editor inputs handle events before hiding them from the page. event.stopImmediatePropagation(); if (!submission) return; event.preventDefault(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 4524b0d017b61..5b9fb3c13bc37 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -11,11 +11,7 @@ import { type RecordingKeyPress, } from "./RecordingInput.ts"; -/** - * Chromium's capture cursor uses native window bounds, which do not follow a - * webview's CSS placement or scale. Draw it in the guest's coordinate space - * while recording, and make the native cursor transparent to avoid two cursors. - */ +/** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */ export function installRecordingCursor( document: Document, window: Window, diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 182b6a505d166..572a290e2b669 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -6,7 +6,6 @@ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { if (pair !== pair.trim()) { - // Electron's parser doesn't trim; surface the bug as undefined-key. out[pair] = pair.split("=")[1]; continue; } @@ -21,9 +20,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { - // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so - // `contextIsolation="no"` would silently leave isolation ENABLED. Lock - // the values to `"true"` / `"false"` so the parser does the right thing. for (const value of Object.values(parsed)) { expect(value).toMatch(/^(true|false)$/); } diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 4236b78293ab1..4dc9d9aa6b732 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,9 +1,3 @@ -/** - * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical - * when paired with `contextIsolation=false` — without sandbox, the preload - * has full Node access (`require`, `fs`, `child_process`, ...) and that - * `require` would land on the page's shared `globalThis`, giving any - * third-party page in the preview full Node + IPC access to the host. - */ +/** sandbox:true is required with contextIsolation:false or the shared page global would expose Node and IPC. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 832a774577149..0135225f90c48 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -90,6 +91,37 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("records one bounds operation span with its input attributes", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true); + }), + ).pipe(Effect.provideService(Tracer.Tracer, tracer)); + const operationSpans = spans.filter( + (span) => span.name === "desktop.settings.setMainWindowBounds", + ); + assert.lengthOf(operationSpans, 1); + assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), { + x: 10, + y: 20, + width: 900, + height: 600, + isMaximized: true, + }); + assert.equal(operationSpans[0]!.status._tag, "Ended"); + }), + ); + it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295fa..5767057b4a35d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -37,8 +37,7 @@ export interface DesktopSettings { readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // Changing this requires a desktop restart because the pool's primary spec is - // chosen once at layer init. + // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; } @@ -466,7 +465,6 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }); /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -523,48 +521,56 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( - Effect.withSpan("desktop.settings.setMainWindowBounds", { - attributes: { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - }, - }), - ), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), - ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), - setWslBackendEnabled: (enabled) => - persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), - ), - setWslDistro: (distro) => - persist((settings) => setWslDistro(settings, distro)).pipe( - Effect.withSpan("desktop.settings.setWslDistro", { - attributes: { distro: distro ?? null }, - }), - ), - setWslOnly: (enabled) => - persist((settings) => setWslOnly(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), - ), - setLocalEnvironmentEnabled: (enabled) => - persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), - ), + setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")( + function* (bounds, isMaximized) { + yield* Effect.annotateCurrentSpan({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + + return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) { + yield* Effect.annotateCurrentSpan({ mode }); + + return yield* persist((settings) => setServerExposureMode(settings, mode)); + }), + setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) { + yield* Effect.annotateCurrentSpan(input); + + return yield* persist((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { + yield* Effect.annotateCurrentSpan({ channel }); + + return yield* persist((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslBackendEnabled(settings, enabled)); + }), + setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) { + yield* Effect.annotateCurrentSpan({ distro: distro ?? null }); + + return yield* persist((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( + function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -596,18 +602,38 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), - setServerExposureMode: (mode) => - update((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), - setWslBackendEnabled: (enabled) => - update((settings) => setWslBackendEnabled(settings, enabled)), - setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), - setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), - setLocalEnvironmentEnabled: (enabled) => - update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")( + function (bounds, isMaximized) { + return update((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")( + function (mode) { + return update((settings) => setServerExposureMode(settings, mode)); + }, + ), + setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { + return update((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { + return update((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")( + function (enabled) { + return update((settings) => setWslBackendEnabled(settings, enabled)); + }, + ), + setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) { + return update((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { + return update((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( + function (enabled) { + return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 51c3d31daae85..1a927643be628 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,8 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, - dismissedProviderUpdateNotificationKeys: [], diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", @@ -64,7 +62,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 7c65c35cf0439..0836db6678226 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -194,8 +194,8 @@ export const make = Effect.gen(function* () { get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), - set: (settings) => - crypto.randomUUIDv4().pipe( + set: Effect.fn("desktop.clientSettings.set")(function (settings) { + return crypto.randomUUIDv4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError( (cause) => @@ -214,8 +214,8 @@ export const make = Effect.gen(function* () { suffix, }), ), - Effect.withSpan("desktop.clientSettings.set"), - ), + ); + }), }); }); @@ -228,7 +228,9 @@ export const layerTest = (initialSettings: Option.Option = Optio const settingsRef = yield* Ref.make(initialSettings); return DesktopClientSettings.of({ get: Ref.get(settingsRef), - set: (settings) => Ref.set(settingsRef, Option.some(settings)), + set: Effect.fn("DesktopClientSettings.set")(function (settings) { + return Ref.set(settingsRef, Option.some(settings)); + }), }); }), ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 248fb9b6f8482..cccc0585024af 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -221,7 +221,7 @@ function decodeSecretBytes( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 3fdec7932366b..25c37ecefc051 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [ "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", "__MISE_DIFF", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; @@ -360,11 +361,6 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { - // Concurrent, not sequential: these two probes are independent (only their - // results are combined below) and each spawns its own PowerShell. Run in - // series they sit at offset 0 of desktop.startup, before anything else, and - // launch traces measured them at 2718ms then 2066ms — the entire 4.8s - // startup span, of which desktop.bootstrap is ~30ms. const [noProfile, profile] = yield* Effect.all( [ readWindowsEnvironment(["PATH"], { loadProfile: false }), @@ -432,9 +428,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. + // Forward mise's PATH delta too, or mise x can rebuild PATH into a recursive same-named wrapper. "__MISE_DIFF", ] as const; for (const name of shellPreferredEnvNames) { @@ -452,21 +446,19 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", - // CA trust set in the shell profile. Without it, a Node or OpenSSL build - // that has no bundled roots (a shared-OpenSSL node on PATH) cannot verify - // TLS, so provider updaters and agents fail to reach their registries. + // Shell CA settings are required by Node/OpenSSL builds without bundled roots for registry TLS. "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "T3CODE_TELEMETRY_ENABLED", + "T3CODE_GITHUB_APP_CLIENT_ID", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } - // Locale variables form one precedence group: LC_ALL can override an inherited - // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) @@ -478,11 +470,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // GUI launches inherit no locale from launchd, so spawned agents land in the C - // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported - // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale - // available on those releases. Leaving LANG unset keeps C-stable collation and - // formatting, so output parsing is unaffected. + // launchd lacks locale; UTF-8 LC_CTYPE fixes pbcopy's MacRoman decoding without changing C formatting or collation. if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { config.env.LC_CTYPE = FALLBACK_LC_CTYPE; } @@ -522,7 +510,7 @@ const installShellEnvironment = ( return Effect.succeed(false); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index f62669a39bf36..20f56cc19442c 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -6,10 +6,7 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; -/** - * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what - * the capture backends key on. - */ +/** id is CGWindowNumber on macOS or HWND on Windows, matching the capture backend. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; readonly id: number; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 1e07d901ec878..a33b184d093b0 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -798,7 +798,6 @@ it.effect.each(["darwin", "win32"] as const)( for (const finish of heldFlights) finish(); yield* Fiber.join(landing); - // Landing releases the guard even before the first capture is acknowledged. yield* Effect.promise(fixture.trigger); assert.equal(fixture.state.snapshots, 2); assert.lengthOf(fixture.readyIds, 2); @@ -1049,9 +1048,15 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( - "persists %s text without inventing AT-SPI screen coordinates", - (backend) => { +it.effect.each([ + { backend: "gnome", screenshot: "PNG" }, + { backend: "niri", screenshot: "PNG" }, + { backend: "kde", screenshot: "PNG" }, + { backend: "hyprland", screenshot: "PNG" }, + { backend: "gnome", screenshot: "invalid" }, +] as const)( + "persists $backend accessibility text with a $screenshot screenshot", + ({ backend, screenshot }) => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); flashWindows.length = 0; const window = { @@ -1068,7 +1073,16 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( ...(backend === "niri" ? { accessibilityBoundsReliable: false } : {}), ...(backend === "kde" ? { clientBounds: { x: 479, y: 371, width: 700, height: 520 } } : {}), }; - linuxCaptureMock.mockResolvedValueOnce({ png: Buffer.from([1, 2, 3]), window }); + // The captured pixels differ from the compositor's logical window bounds. + const imageSize = screenshot === "PNG" ? { width: 1400, height: 1040 } : window.bounds; + const png = + screenshot === "PNG" + ? Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAABXgAAAQQAQAAAABwoA4ZAAAAyElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgYyx4AAepc/c0AAAAASUVORK5CYII=", + "base64", + ) + : Buffer.from([1, 2, 3]); + linuxCaptureMock.mockResolvedValueOnce({ png, window }); activeWindowMock.mockClear(); getSourcesMock.mockClear(); accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -1108,7 +1122,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( assert.deepInclude(saved.source.accessibility, { format: "element-tree", coordinateSpace: "captured-image", - imageSize: { width: 700, height: window.bounds.height }, + imageSize: { width: imageSize.width, height: imageSize.height }, truncated: false, }); assert.deepInclude( @@ -1118,7 +1132,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( { role: "window", name: "Editor", - bounds: { x: 0, y: 0, width: 700, height: window.bounds.height }, + bounds: { x: 0, y: 0, width: imageSize.width, height: imageSize.height }, state: { active: true }, }, ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 9e49cfb49b87f..bd4c8e6d4befd 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers. +import { pipe } from "effect/Function"; import { DEFAULT_CLIENT_SETTINGS, DesktopPendingSnapShot, @@ -90,6 +91,7 @@ import { snapShotShortcutSystemConflict, } from "./snapShot.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -211,7 +213,6 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError 0 && size.height > 0) return size; - } catch {} + // Capture backends already validate or encode the PNG. Reading its header + // avoids decoding every pixel again just to size accessibility coordinates. + const size = readImageDimensions(png); + if (size) return size; return { width: Math.max(1, Math.round(fallback.width)), height: Math.max(1, Math.round(fallback.height)), @@ -609,7 +606,7 @@ function createSnapShotFlashWindow(bounds: Electron.Rectangle): Electron.BaseWin return window; } -export class SnapShotFlash { +class SnapShotFlash { private flashWindow: Electron.BaseWindow | undefined; private animationTimer: ReturnType | undefined; private closeTimer: ReturnType | undefined; @@ -903,8 +900,7 @@ export const make = Effect.gen(function* () { const imageTempPath = path.join(captureDirectory, `${id}.tmp.png`); return yield* Effect.gen(function* () { - // Retire feedback before reading screen pixels, so a rapid capture cannot - // photograph the previous capture's overlay. + // Retire feedback before capture so rapid captures cannot photograph the previous overlay. closeLinuxFeedback(); flash.dispose(); transition.dispose(); @@ -1033,8 +1029,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.snapShot.capture")); const captureFromShortcut = Effect.gen(function* () { - // Repeated shortcuts must not tear down a moving capture and start another - // renderer. Drop them while the flight runs, just as during pixel acquisition. + // Repeated shortcuts must not replace a capture during its flight. if (shortcutSuppressed || transition.isAnimating) return; shortcutVerified = true; const now = yield* Clock.currentTimeNanos; @@ -1140,9 +1135,7 @@ export const make = Effect.gen(function* () { transition.dispose(); closeLinuxFeedback(); } - // Every client-settings save lands here. Only the fields that decide which - // shortcut listener runs may tear it down; a font-size change must not - // uninstall a global keyboard hook or drop an approved portal session. + // Unrelated settings saves must not uninstall the keyboard hook or drop an approved portal session. const shortcutInputsChanged = settings.snapShotEnabled !== previousSettings.snapShotEnabled || settings.snapShotIncludeAccessibility !== previousSettings.snapShotIncludeAccessibility || @@ -1296,10 +1289,10 @@ export const make = Effect.gen(function* () { if (isModifierPairShortcut(shortcut)) { registered = yield* Effect.tryPromise(() => startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void runPromise( - setShortcutFailure( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - ), + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, ).catch(() => undefined); }), ).pipe( @@ -1726,24 +1719,28 @@ export const make = Effect.gen(function* () { ), ), ), - setAnimationDestination: (id, destination) => - Effect.promise(async () => { - if (linuxFeedback?.id === id && destination.relativeFrame) { - await linuxFeedback.feedback - .animateTo(destination.relativeFrame) - .catch(() => closeLinuxFeedback(id)); - return; - } - transition.animateTo(id, destination); - await transition.waitForLanding(id); - }), - dismissAnimation: (id) => - Effect.sync(() => { + setAnimationDestination: Effect.fn("DesktopSnapShot.setAnimationDestination")( + function (id, destination) { + return Effect.promise(async () => { + if (linuxFeedback?.id === id && destination.relativeFrame) { + await linuxFeedback.feedback + .animateTo(destination.relativeFrame) + .catch(() => closeLinuxFeedback(id)); + return; + } + transition.animateTo(id, destination); + await transition.waitForLanding(id); + }); + }, + ), + dismissAnimation: Effect.fn("DesktopSnapShot.dismissAnimation")(function (id) { + return Effect.sync(() => { closeLinuxFeedback(id); transition.dismiss(id); - }), - acknowledge: (id) => - Effect.promise(async () => { + }); + }), + acknowledge: Effect.fn("DesktopSnapShot.acknowledge")(function (id) { + return Effect.promise(async () => { await completeLinuxFeedback(id); await transition.complete(id); }).pipe( @@ -1759,7 +1756,8 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }), ), - ), + ); + }), }); }); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 068fb79733090..0c65eae246e96 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -182,8 +182,7 @@ export class KdeCaptureSetup { } // KService must see the new desktop entry before KWin checks the executable. await run("kbuildsycoca6", ["--noincremental"]); - // AppImages change XDG search paths, and KService caches are also locale-specific. - // Refresh Plasma's cache in its session environment, not just the app's cache. + // AppImage search paths and locale affect KService caches; refresh in Plasma's session environment. await run("systemd-run", [ "--user", "--quiet", diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index ad7b2e03c4954..117ffa652566b 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -11,8 +11,7 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record { send(event, { ScreenshotCaptured: { path: "/another-app.png" } }); send(event, { ScreenshotCaptured: { path: screenshot.path } }); } - // The event may beat the command reply. send(socket, { Ok: "Handled" }); } else send(socket, { Ok: "Handled" }); }; diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index 374745c96e60c..f0ac7b9c1bba2 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,4 @@ -// Runs in a forked Node-mode child so the native screenshot call cannot crash -// or stall the main process. +// Isolate native screenshot crashes and stalls in a Node-mode child. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 3361aafc3c78a..9021c8904e3fe 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -172,8 +172,7 @@ async function readCapturedWindowAccessibility( .catch(() => { progress.flatComplete = true; }); - // A decorated screenshot contains more than the accessibility client area. Keep its - // frame origin/scale so element coordinates include the actual decoration offset. + // Preserve the screenshot frame origin and scale so accessibility coordinates include decorations. const sourceBounds = matchMode === "wayland" && active.clientBounds ? active.bounds diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts index dd1474b885052..542e3f13c08dc 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts @@ -49,8 +49,7 @@ export function startSnapShotAccessibilityProcess(workerPath: string): Accessibi let worker: NodeChildProcess.ChildProcess; try { worker = NodeChildProcess.fork(workerPath, ["read"], { - // Electron defaults to its Helper executable on macOS, which does not - // share the main app's Accessibility grant checked during setup. + // Electron's default macOS Helper executable lacks the main app's Accessibility grant. execPath: process.execPath, env: { ...process.env, diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 2edc0c1f99d14..e7a16f259fbfd 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -310,8 +310,7 @@ export class SnapShotTransition { } } if (this.waitForCompositorFrame) { - // Cover the source with a composited snapshot before the caller reveals T3. - // Decoding and renderer animation frames alone can leave a transparent gap. + // Renderer frames alone can leave a transparent gap; composite the snapshot before revealing T3. await Promise.all( overlays.map(async (overlay) => { if (this.active !== active || overlay.window.isDestroyed()) return; @@ -408,8 +407,7 @@ export class SnapShotTransition { ); if (this.active !== active || overlay.window.isDestroyed()) return; if (this.waitForCompositorFrame) { - // Read back one pixel after preparation to flush queued compositor work - // before the animation clock starts on any of the display surfaces. + // Read back one pixel to flush compositor work before starting any display's animation clock. await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); } return overlay; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts index eb02eb7a03d13..ca4165626b260 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts @@ -4,8 +4,7 @@ const WINDOW_ANIMATIONS_DISABLED = "wm-window-animations-disabled"; /** Show capture feedback without Aura's extra 200 ms fade/scale on transparent windows. */ export function showWindowsCaptureOverlay(window: Electron.BaseWindow): void { - // Chromium reads this switch synchronously inside Show(). Restore it before returning - // so other windows and CSS animations keep their normal behavior. + // Show() reads the switch synchronously; restore it before other windows or animations can be affected. const alreadyDisabled = Electron.app.commandLine.hasSwitch(WINDOW_ANIMATIONS_DISABLED); if (!alreadyDisabled) Electron.app.commandLine.appendSwitch(WINDOW_ANIMATIONS_DISABLED); try { diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index c35fee18db45e..59fe40b7c5212 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -4,8 +4,7 @@ import * as NodeURL from "node:url"; import type { SnapShotKeyChord } from "@t3tools/contracts"; import { nativeImage } from "electron"; -// Linux helpers that need no D-Bus. Keep this module free of dbus-next so the main -// process can answer "which desktop is this" and read a PNG without loading it. +// Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; diff --git a/apps/desktop/src/snapShot/snapShot.test.ts b/apps/desktop/src/snapShot/snapShot.test.ts index 26ae8fbf9ebe9..7c63a8b9aadef 100644 --- a/apps/desktop/src/snapShot/snapShot.test.ts +++ b/apps/desktop/src/snapShot/snapShot.test.ts @@ -8,8 +8,6 @@ import { accessibleWindowText, compactAccessibilityTree, findAccessibleWindow, - findCaptureSource, - hideAndWaitForBlur, isWaylandSession, } from "./snapShot.ts"; @@ -450,68 +448,6 @@ describe("findAccessibleWindow", () => { }); }); -describe("hideAndWaitForBlur", () => { - it("waits for a delayed blur after hiding the window", async () => { - let blur: (() => void) | undefined; - let settled = false; - const hidden = hideAndWaitForBlur({ - hide: () => undefined, - once: (_event, listener) => { - blur = listener; - }, - removeListener: () => undefined, - }).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - blur?.(); - await hidden; - expect(settled).toBe(true); - }); -}); - -describe("findCaptureSource", () => { - const sources = [ - { id: "window:42:0", name: "Terminal" }, - { id: "window:84:0", name: "Editor" }, - ]; - - it("matches the native window id before its title", () => { - expect( - findCaptureSource(sources, { - id: 84, - title: "Changed title", - }), - ).toEqual(sources[1]); - }); - - it("falls back to a unique title match", () => { - expect( - findCaptureSource(sources, { - id: 100, - title: "Terminal", - }), - ).toEqual(sources[0]); - }); - - it("does not guess when a title is ambiguous", () => { - expect( - findCaptureSource( - [ - { id: "window:42:0", name: "Editor" }, - { id: "window:84:0", name: "Editor" }, - ], - { - id: 100, - title: "Editor", - }, - ), - ).toBeUndefined(); - }); -}); - describe("isWaylandSession", () => { effectIt.effect( "falls back to a live runtime directory socket when session variables are stripped", diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 63484aa2fa9f7..872f99e1d6a72 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -23,7 +23,6 @@ interface AccessibilityTreeNode { } const MAX_ACCESSIBILITY_TREE_NODES = 10_000; -const WINDOW_BLUR_TIMEOUT_MS = 1_000; export const WINDOWS_MODIFIER_PAIR_VIRTUAL_KEYS: Record< SnapShotModifier, @@ -438,25 +437,6 @@ export async function accessibleWindowElementTree( return { root: compacted.root, truncated: completedTruncated }; } -export function hideAndWaitForBlur(window: { - readonly hide: () => void; - readonly once: (event: "blur", listener: () => void) => unknown; - readonly removeListener: (event: "blur", listener: () => void) => unknown; -}): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - window.removeListener("blur", onBlur); - reject(new Error("Timed out waiting for T3 Agents to lose focus.")); - }, WINDOW_BLUR_TIMEOUT_MS); - const onBlur = () => { - clearTimeout(timeout); - resolve(); - }; - window.once("blur", onBlur); - window.hide(); - }); -} - type WindowBounds = { readonly x: number; readonly y: number; @@ -520,8 +500,7 @@ export function findAccessibleWindow< const browserMatch = findBrowserTitleMatch(windows, titles, captured, matchMode, matchesBounds); if (browserMatch) return browserMatch; } - // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped - // lookup can accept the one window whose bounds match; size-only guesses cannot. + // GTK4 frames may be unnamed; accept only a PID-scoped unique bounds match, never size alone. if (!options.allowUntitledUniqueBounds) return undefined; const boundsMatches = windows.filter( (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), @@ -592,30 +571,6 @@ export function toElectronAccelerator(shortcut: SnapShotKeyChord): string { return parts.join("+"); } -interface CaptureSourceLike { - readonly id: string; - readonly name: string; -} - -interface ActiveWindowLike { - readonly id: number; - readonly title: string; -} - -export function findCaptureSource( - sources: readonly T[], - activeWindow: ActiveWindowLike, -): T | undefined { - const idPrefix = `window:${activeWindow.id}:`; - const idMatch = sources.find((source) => source.id.startsWith(idPrefix)); - if (idMatch) return idMatch; - - const title = activeWindow.title.trim(); - if (!title) return undefined; - const titleMatches = sources.filter((source) => source.name.trim() === title); - return titleMatches.length === 1 ? titleMatches[0] : undefined; -} - export function isWaylandSession( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 4c39bb63c70e5..102eb74cfead8 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -125,7 +125,7 @@ const makePasswordPrompt = ( prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; @@ -133,32 +133,26 @@ export const make = Effect.gen(function* () { const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + discoverHosts: Effect.fn("desktop.ssh.discoverHosts")(function* (input) { + return yield* discoverDesktopSshHostsEffect(input); + }, Effect.provide(runtimeContext)), + resolveHost: Effect.fn("desktop.ssh.resolveHost")(function* (alias) { + return yield* resolveSshTarget(alias.trim()); + }, Effect.provide(runtimeContext)), + ensureEnvironment: Effect.fn("desktop.ssh.ensureEnvironment")( + function* (target, ensureOptions) { + return yield* manager.ensureEnvironment(target, ensureOptions); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: Effect.fn("desktop.ssh.disconnectEnvironment")( + function* (target) { + return yield* manager.disconnectEnvironment(target); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 48f95adf6e3cf..1eebbb92fc238 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -218,7 +218,7 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( options: DesktopSshPasswordPromptsOptions = {}, ) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 5e4b1de81b833..8800c4f428331 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -141,7 +141,7 @@ function sampleInterval( return LIVE_SAMPLE_INTERVAL; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 49acc3f86a483..253a015d8ec9e 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -22,9 +22,7 @@ import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { makeHarness } from "./updatesTestHarness.ts"; -// The remote flow hops between the test runtime's fibers and the updater's -// runPromise-driven event handlers, so settling needs real microtask turns, -// not just fiber yields. +// Updater runPromise handlers need real microtasks between test-runtime fiber yields. const settle = Effect.gen(function* () { for (let i = 0; i < 20; i += 1) { yield* Effect.yieldNow; @@ -326,8 +324,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("retries a download refused while the check still holds the reservation", () => { - // electron-updater emits update-available from inside checkForUpdates, - // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -359,9 +355,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("waits for the download reservation before reporting prepared", () => { - // update-downloaded fires from inside downloadUpdate. If the flow - // reported "installing" right then, install would be refused for the - // held reservation after the irrevocable terminal already went out. const releaseDownload = Deferred.makeUnsafe(); const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb1b86c07754b..cb606d8fbd253 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -175,11 +175,7 @@ export const listen: Effect.Effect< const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; - // A state event can land while the action that produced it still holds - // the reservation (e.g. "available" before the check releases), so a - // forked action can be refused with no later state event to retry on. - // Rejected actions re-enqueue their state here after a short pause so - // the step runs again once the reservation is free. + // State events can precede reservation release; re-enqueue refused actions so they retry after release. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -416,11 +412,7 @@ export const listen: Effect.Effect< result.state.downloadedVersion === claim.prepared.downloadedVersion && (yield* updates.isInstallActive) ) { - // Another install (local, or an earlier remote request) already owns - // the shutdown and will relaunch the app on the same downloaded - // version. This commit joins it: no failure marker, and the client - // proves the handoff the same way, by transport loss then the - // target version on reconnect. + // Join the existing install; reconnect and target-version checks prove its handoff. yield* logInfo("remote update commit joining an in-progress install", { requestId: commit.requestId, }); @@ -433,9 +425,7 @@ export const listen: Effect.Effect< } return; } - // A successful install tears down this backend. Do not send a success - // marker from the old process: transport loss followed by the target - // version is the only proof that the handoff succeeded. + // Installation kills this backend; only transport loss followed by the target version proves handoff. }).pipe( Effect.ensuring(clearActiveRequest(commit.requestId)), Effect.catchCause((cause) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 633d94f5366da..c722270094513 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -64,9 +64,7 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, - // Left unvalidated on purpose: a malformed release-notes payload must never - // fail the decode and block the update state transition. The shape is - // validated defensively in normalizeDesktopUpdateReleaseNotes. + // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), }); @@ -158,8 +156,6 @@ export class DesktopUpdateUnexpectedActionError extends Schema.TaggedError; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; - readonly configure: Effect.Effect; + readonly configure: Effect.Effect; readonly setChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; @@ -335,7 +331,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; @@ -364,8 +360,7 @@ export const make = Effect.gen(function* () { ); const stateChanges = yield* PubSub.sliding(16); - // Makes ref writes + publishes atomic against subscribe, so a snapshot - // never overlaps with the first change a subscriber receives. + // Serialize writes and publishes against subscription so snapshots cannot overlap the first change. const stateMutex = yield* Semaphore.make(1); const emitState = Ref.get(updateStateRef).pipe( @@ -399,8 +394,7 @@ export const make = Effect.gen(function* () { ), ); - // The .deb carries electron-builder's resources/package-type marker. - // electron-updater reads the same file and installs updates with dpkg. + // electron-updater uses this marker to install .deb updates with dpkg. const isDebPackage = environment.platform === "linux" && environment.isPackaged ? yield* fileSystem @@ -597,8 +591,7 @@ export const make = Effect.gen(function* () { ), ); - // A failed or interrupted install brings no updated backend, so a later - // quit must release the tunnel. + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); @@ -742,11 +735,7 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop every backend in the pool, not just the primary. With parallel - // WSL + Windows backends, leaving the WSL instance up means - // quitAndInstall's app.quit() exits before the pool's scope cascade - // has a chance to run its stop finalizer, so the WSL child gets - // hard-killed by the OS instead of receiving SIGTERM + grace. + // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => @@ -1171,7 +1160,9 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: Effect.fn("DesktopUpdates.installPrepared")(function (expectedVersion) { + return installWithExpectedVersion(expectedVersion); + }), }); }); diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 008a83eab03db..028e42fa6e3b7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -81,8 +81,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("drops releases from other trains before grouping on nightly", () => { - // electron-updater's full changelog is "every version above the running - // one", and preview sorts above nightly, so the preview cuts come first. const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index ef6d217299d09..69d710ff7b269 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -30,8 +30,6 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { }; function decodeCodePoint(codePoint: number, entity: string): string { - // String.fromCodePoint throws RangeError outside the valid Unicode range, and - // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } @@ -124,12 +122,7 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } -/** - * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever train - * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` - * of the same base version and would lead the list. - */ +/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index f7c65b35820cc..1d9588315559c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -11,12 +11,7 @@ export type RemoteDesktopUpdateStep = readonly reason?: string; }; -/** - * The caps are what stop a check -> up-to-date -> check loop and endless - * download retries; they are counts rather than booleans because a state event - * raced by the local 4-minute poller can re-show an already-handled status - * once. - */ +/** Bound retries with counts: background poller races can repeat an already-handled status once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; readonly downloads: number; @@ -46,9 +41,7 @@ export function nextRemoteDesktopUpdateStep( reason: disabledReason ?? "Automatic updates are disabled on this machine.", }; } - // An install of this version already failed to apply here. Installing it - // again from afar would stop the app's threads for nothing, so only the user - // on this machine retries it. + // Reinstalling a failed version disrupts threads without updating; retries require the local user. if ( state.notAppliedVersion !== undefined && (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion @@ -59,11 +52,7 @@ export function nextRemoteDesktopUpdateStep( reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`, }; } - // Mirror installDownloadedUpdate's own admission rule exactly, so the run - // never reports an install that the updater then refuses. A download - // survives an unrelated background updater error (errorContext null) and - // a previous failed install (errorContext "install"); it does not survive - // a check or download error, which fall through to the error branch. + // Match install admission, including retained downloads after unrelated background or earlier install errors. if (isInstallableDesktopUpdateState(state)) { return { action: "install" }; } @@ -80,9 +69,7 @@ export function nextRemoteDesktopUpdateStep( } return { action: "download" }; } - // "up-to-date" and "error" are retained from earlier/background checks, - // so before this run has issued its own check they are stale, not - // terminal: the whole point of a remote request is to look again. + // Background up-to-date/error states are stale until this request has issued its own check. if (state.status === "up-to-date") { if (attempts.checks === 0) { return { action: "check" }; @@ -109,11 +96,7 @@ export function nextRemoteDesktopUpdateStep( return { action: "check" }; } -/** - * Updater messages are plain strings and may be blank; a blank reason would - * fail encoding and silently drop the terminal report, leaving the server to - * wait for its timeout. - */ +/** Blank reasons fail encoding and drop the terminal report, forcing the server to time out. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed ? trimmed : undefined; diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 1b1a1ec1efa99..d79a374a668a5 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,10 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// They share nightly's branding but are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel -// a preview install reports is cosmetic: it never checks for updates and no -// updater feed ever lists a preview release. +// 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 { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index df7e1fd2e85ac..9d8efd056665b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -199,6 +199,41 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("never binds Cmd/Ctrl+W to closing the window", () => + Effect.gen(function* () { + for (const platform of ["darwin", "linux", "win32"] as const) { + const applicationMenuTemplate = + yield* Deferred.make(); + yield* configureMenu(yield* Deferred.make(), applicationMenuTemplate, { + platform, + }); + + const items: Electron.MenuItemConstructorOptions[] = []; + const collect = (template: readonly Electron.MenuItemConstructorOptions[]) => { + for (const item of template) { + items.push(item); + if (Array.isArray(item.submenu)) collect(item.submenu); + } + }; + collect(yield* Deferred.await(applicationMenuTemplate)); + + assert.isUndefined( + items.find((item) => item.role === "close"), + platform, + ); + // Electron's default windowMenu submenu includes a close role. + assert.isUndefined( + items.find((item) => item.role === "windowMenu" && item.submenu === undefined), + platform, + ); + assert.isUndefined( + items.find((item) => item.accelerator?.toLowerCase().endsWith("+w")), + platform, + ); + } + }), + ); + it.effect("reloads the main window even while a browser page has focus", () => Effect.gen(function* () { const selectedAction = yield* Deferred.make(); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 5812fc07be332..b78d71126e80b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,14 +106,13 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { return; } - // The result dialogs need no main window. Opening one before the backend - // answers strands the renderer on a failed auth bootstrap. + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -143,10 +142,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - // Chromium already pastes as plain text for this chord, so the accelerator - // needs nothing from the menu: the composer and the terminal each arm - // themselves from the same keydown. Routing it through the renderer anyway - // lands a second, injected paste and doubles the text. + // Chromium and the renderer already handle this chord; injecting another paste duplicates text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -204,7 +200,10 @@ export const make = Effect.gen(function* () { }, { type: "separator" as const }, ]), - { role: environment.platform === "darwin" ? "close" : "quit" }, + // Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window. + environment.platform === "darwin" + ? { label: "Close Window", click: (_item, window) => window?.close() } + : { role: "quit" }, ], }, { @@ -238,11 +237,7 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - /* - Not the reload, DevTools or zoom roles: those act on the focused - webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. - */ + /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { label: "Force Reload", @@ -274,8 +269,7 @@ export const make = Effect.gen(function* () { ] : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, - // The numeric keypad sends its own key codes, so the number-row - // accelerators above never match it. + // Keypad codes do not match number-row accelerators. { label: "Actual Size", accelerator: "CmdOrCtrl+num0", @@ -298,7 +292,17 @@ export const make = Effect.gen(function* () { { role: "togglefullscreen" }, ], }, - { role: "windowMenu" }, + { + // Electron's default Window submenu adds a Ctrl+W close role off macOS. + role: "windowMenu", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(environment.platform === "darwin" + ? [{ type: "separator" as const }, { role: "front" as const }] + : []), + ], + }, { role: "help", submenu: [ diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 6d081c23d2399..8aa81da9f8943 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -592,8 +592,6 @@ describe("DesktopWindow", () => { }), ); - // Ctrl+wheel bypasses the menu accelerators, so it has to take the same - // zoom step, preview restore included, from the window's zoom-changed event. it.effect("zooms the app from Ctrl+wheel like the menu does", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1faab18043ce8..ded89390cdea4 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -38,8 +38,7 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. +// Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; const MACOS_WINDOW_BUTTON_RADIUS = 7; @@ -123,9 +122,7 @@ export class DesktopWindow extends Context.Service< readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // The Electron `zoomIn`/`zoomOut` menu roles act on whichever webContents - // has keyboard focus, so with an embedded preview WebContentsView (or - // DevTools) focused they zoom the guest page instead of the app UI. + // Electron zoom roles target focused guests; zoomMain must always target the app. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; @@ -148,7 +145,7 @@ function getIconOption( } function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; + return shouldUseDarkColors ? "#000000" : "#ffffff"; } type DisplayBounds = Pick; @@ -236,8 +233,7 @@ function concealPendingQuitWindow( if (window.isFullScreen()) { window.setFullScreen(false); } - // Electron implements window opacity on macOS and Windows. Linux keeps the - // release-gated quit behavior but cannot make the pending window disappear. + // Linux lacks Electron window opacity; release-gated quits remain visible there. window.setOpacity(0); } @@ -300,7 +296,7 @@ function bindFirstRevealTrigger( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopState = yield* DesktopState.DesktopState; @@ -330,11 +326,7 @@ export const make = Effect.gen(function* () { } }); - // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", - // which during WSL-only boot is the connecting splash. The splash is never - // registered via setMain, so it must be treated as "no real main window" -- - // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never - // open (or retry) the real main. + // Electron's first-window fallback can select the unregistered connecting splash; exclude it so main creation retries. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -356,9 +348,7 @@ export const make = Effect.gen(function* () { direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); if (environment.platform === "darwin") syncMacosWindowButtons(window); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. + // App zoom propagates to guests; restore each preview's own zoom. yield* previewManager.reapplyZoom(); }); @@ -409,18 +399,13 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // Chromium throttles hidden renderers: timers coalesce and rAF stops, - // which stalls first paint. Boot unthrottled; the first-reveal trigger - // re-enables throttling so a hidden or minimized window goes back to - // being cheap after it has been shown once. + // Hidden-renderer throttling stalls first paint; re-enable it after the first reveal. backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, - // A preview guest's fullscreen request is mirrored onto this embedder, - // which would otherwise put the whole window into OS fullscreen. With - // both sides opted out the page fills its webview and the window stays. + // Guest fullscreen propagates to the embedder; both must opt out to keep it inside the webview. disableHtmlFullscreenWindowResize: true, }, }); @@ -556,8 +541,7 @@ export const make = Effect.gen(function* () { contents.on("context-menu", (event, params) => { event.preventDefault(); if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; - // Native editing roles act on the focused contents, which may still be - // the host renderer when the user right-clicks inside a browser guest. + // Right-clicking a guest may leave native editing roles targeting the host renderer. contents.focus(); const menuTemplate: Electron.MenuItemConstructorOptions[] = []; @@ -650,9 +634,6 @@ export const make = Effect.gen(function* () { } }); - // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the - // close-terminal shortcut can outlive the terminal that handled its first - // press, so reject repeats before they reach the native window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -670,8 +651,7 @@ export const make = Effect.gen(function* () { window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, - // Keep the transparent window focused until the physical shortcut is - // released so its remaining repeats cannot reach the next app. + // Hold focus until shortcut release so remaining repeats cannot reach the next app. concealWindow: () => concealPendingQuitWindow(window), quit: () => { void runPromise(electronApp.quit); @@ -679,6 +659,7 @@ export const make = Effect.gen(function* () { }); window.webContents.on("before-input-event", (event, input) => { quitShortcutHandler(event, input); + // A held Cmd/Ctrl+W closes one tab or terminal, not each one behind it. if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { @@ -688,9 +669,7 @@ export const make = Effect.gen(function* () { window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); }); - // Ctrl+wheel never reaches the menu accelerators: Chromium reports it here - // and leaves applying the zoom to the app. A preview guest under the - // pointer emits this on its own webContents and keeps its own zoom. + // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { if (window.isDestroyed()) return; runFork(zoomWindow(window, direction)); @@ -1016,8 +995,7 @@ export const make = Effect.gen(function* () { const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); - // The renderer must learn about the event even when another process refuses to - // yield the foreground, so send first and treat the reveal as best effort. + // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) : send; @@ -1093,8 +1071,7 @@ export const make = Effect.gen(function* () { }), runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { yield* Effect.annotateCurrentSpan({ command }); - // The registered main window, never the focused one: with an OAuth popup - // focused, Reload would otherwise reload the popup mid sign-in. + // A focused OAuth popup must not receive the app's Reload command. const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) return; const webContents = window.value.webContents; diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index b87ae9ceab29a..f7db33f17ed5e 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -4,11 +4,9 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contr export const QUIT_HOLD_DURATION_MS = 1200; export const QUIT_DOUBLE_PRESS_MS = 500; -// "Still held" is proven by auto-repeat keydowns, not by the absence of a -// release: macOS suppresses a letter keyUp while the command key is down, so a -// tap release can go completely unseen and a release-based timer would quit -// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after -// repeats stop so they cannot reach the next app. +// Auto-repeat keydowns prove the hold: macOS drops a letter keyUp while Command is down, +// so a release-based timer would quit anyway. Once held, quitting waits for Q keyUp or a +// quiet grace period so trailing repeats cannot reach the next app. const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -46,9 +44,7 @@ export function makeQuitShortcutHandler( let lastPressAt = 0; let lastRepeatAt = 0; let repeatCadenceMs = 0; - // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: a direct-mode - // press must still quit after that read settles. + // A key release must not invalidate a pending direct-mode read; only superseding/cancelled presses do. let generation = 0; const clearWatchdog = () => { @@ -125,8 +121,7 @@ export function makeQuitShortcutHandler( if (!modifierDown || input.alt || input.shift || key !== "q") { if (key === modifierKey && !input.alt && !input.shift) return; - // Keep this separate from release(), which also runs when a fresh Q - // keydown follows a keyUp that macOS did not deliver. + // release() also runs on fresh keydown after a missing macOS keyUp. if (!input.isAutoRepeat) { lastPressAt = 0; release(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 56d396e081123..f0c611ae47bad 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -15,8 +15,6 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -// Exported so callers that parse pool ids (e.g. the pickFolder IPC handler in -// ipc/methods/window.ts) reference the same prefix this module produces. export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; @@ -43,9 +41,7 @@ const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => const buildLabel = (distro: string | null): string => distro === null ? "WSL (default distro)" : `WSL (${distro})`; -// Loopback-only port scan starting one above the primary's port. The -// WSL backend is reachable via 127.0.0.1 from Windows (wslhost -// auto-forwards), so we only need to verify the IPv4 loopback can bind. +// WSL auto-forwards loopback ports, so only IPv4 loopback availability matters. const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( startPort: number, ): Effect.fn.Return { @@ -69,11 +65,7 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; - // Serialize reconcile so the bootstrap fork and the IPC handlers - // (setWslBackendEnabled, setWslDistro) can't interleave. Without - // this, two reconciles could both observe "no WSL instance - // registered" between their pool reads and both call startNew - // with different distros, leaving the loser stranded. + // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); const preflightErrorRef = yield* Ref.make(Option.none()); @@ -170,11 +162,7 @@ export const layer = Layer.effect( const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); - // In wsl-only mode the pool's primary IS the WSL backend (see - // DesktopBackendConfiguration.resolvePrimary), so the - // orchestrator skips registering a parallel "wsl:" - // secondary. Without this skip we'd spin up two WSL processes - // on the same distro for users who explicitly asked for one. + // In wsl-only mode WSL is already primary; do not start a second backend on the same distro. const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; const targetId = shouldRun ? Option.some(resolveTargetInstanceId(settings.wslDistro)) @@ -211,8 +199,7 @@ export const layer = Layer.effect( } if (Option.isSome(targetId)) { - // Pre-warm the WSL VM before registering so the readiness probe doesn't - // race wsl.exe's first-spawn cold start. + // Pre-warm WSL so readiness cannot race its cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 711733cb36b54..67549f6b8a54f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -39,8 +39,7 @@ const posixShellRunner = (() => { const runShell = (script: string) => { if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); - // The install script arrives on stdin in production too, which is what lets - // its own /proc scan not match itself. + // Pass the script on stdin, as production does, to avoid matching its own /proc scan. const result = NodeChildProcess.spawnSync( posixShellRunner.file, [...posixShellRunner.args, "-s"], @@ -54,10 +53,7 @@ const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; -// Holds a cache busy the way a running server does: the holder's argv[0] is the -// runtime entry, which the prune and install scripts look for in /proc cmdline. -// It reads a pipe the script keeps open on fd 7, so it exits with the script -// instead of outliving it on a timer. +// Use runtime-entry argv[0] for /proc detection; an open pipe keeps it alive only while this script runs. const holdRuntimeBusy = (entry: string) => [ `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, @@ -124,8 +120,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const archivePath = `${work}/wsl-runtime.tar.gz`; const archiveSha = readField(result.stdout, "archiveSha"); const runtimeId = `sha256-${archiveSha}`; - // The script reads $HOME, and WSL does not inherit the parent process's - // environment, so the home override rides in the script itself. + // WSL does not inherit HOME; embed the fixture's override in the script. const installScript = (archive = archivePath, sha = archiveSha) => [ `HOME=${sh(`${work}/home`)}`, @@ -288,8 +283,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, fixture.installScript(), "T3CODE_INSTALL_SCRIPT", - // Both racers run the same file, and neither file path contains the - // runtime root, so the script's own /proc scan cannot see them. + // Keep runtime roots out of both racers' argv so their /proc scans cannot match themselves. 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', "first=$!", 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 4c9757b99dbbd..e744ef9d6a1b2 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -89,9 +89,7 @@ export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect; - // Best-effort enumeration for renderer UX. Backend health checks must use - // probeDistros so a transient command failure is not mistaken for a - // successful empty installation. + // UX-only best effort; health checks need probeDistros to distinguish failures from an empty installation. readonly listDistros: Effect.Effect; readonly probeDistros: Effect.Effect; readonly preWarm: (distro: string | null) => Effect.Effect; @@ -100,11 +98,7 @@ export class DesktopWslEnvironment extends Context.Service< windowsPath: string, ) => Effect.Effect>; readonly getUserHome: (distro: string | null) => Effect.Effect>; - // The orchestrator uses this for the WSL backend's httpBaseUrl so the - // renderer can reach it without relying on wslhost's localhost→WSL - // automatic forwarding, which is flaky in practice (the backend can be - // listening for 30+ seconds before wslhost starts forwarding 127.0.0.1:port - // to WSL-side localhost). + // Use the distro IP to avoid unreliable wslhost localhost forwarding. readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, @@ -177,8 +171,7 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc " +
+
+
`; + + const screenshotOf = async (html: string) => { + const page = await context.newPage(); + await page.setContent(html); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + // The server has no DOM types, so the decoder runs as page source. + const points = [ + [0.15, 0.75], + [0.85, 0.25], + [0.85, 0.75], + ]; + const pixels = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(screenshot.data)}), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return ${JSON.stringify(points)}.map(([x, y]) => + Array.from(context.getImageData(x * bitmap.width, y * bitmap.height, 1, 1).data), + ); + })()`)) as Array<[number, number, number, number]>; + return { screenshot, pixels }; + }; + const colorOf = ([red, green, blue]: readonly [number, number, number, number]) => + blue - red > 100 ? "blue" : red - blue > 100 ? "red" : `rgb(${red}, ${green}, ${blue})`; + + it("captures a blurred backdrop-filter page as rendered, not mirrored", async () => { + const { screenshot, pixels } = await screenshotOf(halves("backdrop-filter: blur(4px);")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("captures a plain page at the shrunk size with its content in place", async () => { + const { screenshot, pixels } = await screenshotOf(halves("")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { + const page = await context.newPage(); + await page.goto("data:text/xml,
"); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + + expect([screenshot.width, screenshot.height]).toEqual([1280, 800]); + }); +}); + describe("server browser drag", () => { it("fails the drag, not the server, when it times out while the cursor is still moving", async () => { const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded."); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index c6ca0e4a0f8f2..9525ac7c5b579 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -105,7 +105,6 @@ const targetLocator = ( ): Locator | null => { const selector = input.locator ?? input.selector; if (selector === undefined) return null; - // Ignore quoted/escaped CSS values when looking for a selector-engine boundary. const engines = selector.replace( /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, " ", @@ -175,7 +174,6 @@ export const withoutDataPayload = (url: string) => { : ({ t: "\t", n: "\n", r: "\r", '"': '"', "\\": "\\" }[code] ?? "\u0001"), ) .replace(/[\t\n\r]/g, "") - // eslint-disable-next-line no-control-regex -- URL parsing ignores these controls. .replace(/^[\u0000-\u0020]+/, ""); const parsed = /^data:([^,;]*)/i.exec(value); if (!parsed) return url; @@ -207,11 +205,9 @@ const withoutTreeDataPayloads = (tree: string) => // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; -// How long a frame in another process gets to answer; one that crashed never does. const FRAME_ANSWER_MS = 1_000; -// Values the page draws as dots (password fields, and text fields whose CSS -// draws dots), normalized like accessible text. Open shadow roots included. +// Include password and CSS-masked text in open shadow roots, normalized like accessible text. const DOTTED_SCRIPT = `(() => { const values = []; const visit = (root) => { @@ -244,10 +240,10 @@ const tooBusy = () => const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; -/** A frame in another process keeps one CDP session, so none pile up across snapshots. */ +/** Reuse one CDP session per out-of-process frame across snapshots. */ const frameSessions = new WeakMap>(); -/** The value, or undefined when it fails or takes longer than a frame may. */ +/** Returns undefined on failure or frame timeout. */ const answered = (promise: Promise) => { let timer: ReturnType | undefined; const late = new Promise((resolve) => { @@ -256,14 +252,7 @@ const answered = (promise: Promise) => { return Promise.race([promise.catch(() => undefined), late]).finally(() => clearTimeout(timer)); }; -/** - * Reads dotted values in isolated worlds, where page script can't change what `value` - * returns, through the CDP session of the process each frame renders in. A frame in - * another process that can't be attached, as in desktop tabs where only the page can, - * is read in the page's own world, where its script can change `value`. `read` gives - * each reader's values, or undefined for one that didn't answer, and fails when the - * page's own frames don't or frames came or went. - */ +/** Isolated worlds prevent spoofing; desktop out-of-process frames use page worlds. Missing own-frame reads or changed frames fail; other missing reads return undefined. */ const openReaders = async (page: Page, cdp: CDPSession) => { const frames = page.frames(); const owners = new Map( @@ -276,8 +265,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { return [frame, await session] as const; } catch (cause) { frameSessions.delete(frame); - // CdpRelay refuses all but the page. Any other refusal reads the frame through its - // parent's session, which fails the read if the frame isn't there. + // CdpRelay supports only the page; other refusals fall back to the parent session, failing if the frame is absent. const desktop = String(cause).includes("Only this preview tab's page can be attached"); return [frame, desktop ? null : undefined] as const; } @@ -349,10 +337,8 @@ const openReaders = async (page: Page, cdp: CDPSession) => { ]; return { readers, - /** Readers that didn't answer `before` aren't asked again. */ read: async (before?: ReadonlyArray) => { const now = page.frames(); - // A frame added since the readers opened has none. if (now.length !== frames.length || now.some((frame, i) => frame !== frames[i])) { throw pageChanged(); } @@ -366,12 +352,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { }; }; -/** - * Matches each value as the snapshot prints it: raw, as a JSON name, as a - * quoted value, and with '' for each quote inside a quoted key. A value under - * four characters only matches between non-word characters; inside other words - * it would mask unrelated text. - */ +/** Short values need word boundaries to avoid masking unrelated text. */ const dottedPattern = (values: ReadonlyArray) => { const sources = values .flatMap((value) => { @@ -394,8 +375,7 @@ const dottedPattern = (values: ReadonlyArray) => { if (sources.length === 0) return undefined; const pattern = new RegExp(sources.join("|"), "gu"); try { - // Compiles it now, for one-byte and two-byte text: one too large fails only when it - // first runs on each, naming every value. + // Force regex compilation for one-byte and two-byte strings now; size errors name every value. pattern.test(""); pattern.test("\u2022"); } catch { @@ -404,16 +384,11 @@ const dottedPattern = (values: ReadonlyArray) => { return pattern; }; -// `- role "name" [attribute]...: value`, the key single-quoted when it holds ": ". A name -// that starts and ends with "/" prints unquoted. Only Playwright's own attributes count, so -// text around a value in a name can't pass for one. +// Playwright single-quotes keys containing ': ' and leaves /.../ names unquoted. Recognize only its attributes so name text cannot spoof them. const TREE_LINE = /^(\s*- '?[^\s"':]+)( "(?:[^"\\]|\\.)*"| \/(?:.*?\/)?)?((?: \[(?:active|checked(?:=mixed)?|disabled|expanded|level=\d+|pressed(?:=mixed)?|selected|ref=(?:f\d+)?e\d+|cursor=pointer|box=-?\d+,-?\d+,\d+,\d+)\])*'?)(:.*)?$/; -/** - * Masks each value on a line, except one that only matches inside the role or attributes, - * so refs keep working. A "/.../" name has no closing mark, so a value can seem to cross it. - */ +/** Preserve roles and attributes so refs work; unquoted /.../ names can make matches cross boundaries. */ const maskTree = (tree: string, dotted: RegExp) => tree .split("\n") @@ -435,8 +410,6 @@ const pngSize = (base64: string) => { return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; }; -type Viewport = { readonly width: number; readonly height: number }; - /** * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. @@ -463,13 +436,7 @@ const readPageMetrics = async (cdp: CDPSession) => { readonly width: number; readonly height: number; }; - return { - ratio, - width, - height, - zoom, - offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }, - }; + return { ratio, width, height, zoom }; } } throw new Error( @@ -477,41 +444,56 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter` +// in any capture it shrinks. export const captureViewport = async ( - page: Page, cdp: CDPSession, - options: { - readonly format: "png" | "jpeg"; - readonly quality?: number; - readonly scale: number; - readonly viewport?: Viewport; - readonly offset?: { readonly x: number; readonly y: number } | undefined; - }, + options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - let clip; - if (options.scale < 1) { - const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; - let offset = options.offset; - if (!offset) { - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - const zoom = cssVisualViewport.zoom ?? 1; - offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }; - } - clip = { - ...offset, - ...viewport, - scale: options.scale, - }; - } const { data } = await cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip ? { clip } : {}), }); return data; }; +/** Runs in the page's own isolated world, as the server has no image library. */ +const SHRINK_PNG = `async function (png, width, height) { + const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + // Namespaced, as an XML document's createElement makes a plain element with no getContext. + const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.imageSmoothingQuality = "high"; + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return canvas.toDataURL("image/png").slice("data:image/png;base64,".length); +}`; + +const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => { + const size = pngSize(png); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-screenshot", + }); + const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", { + functionDeclaration: SHRINK_PNG, + executionContextId, + arguments: [ + { value: png }, + { value: Math.round(size.width * scale) }, + { value: Math.round(size.height * scale) }, + ], + awaitPromise: true, + returnByValue: true, + }); + if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot."); + return result.value as string; +}; + type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; @@ -522,17 +504,16 @@ type SnapshotInput = { readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }; -/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */ +/** The scale a page renders at and its viewport in device-independent pixels. */ const renderingOf = async (input: SnapshotInput) => { if (input.renderScale !== "display") { const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; - return { renderScale: input.renderScale, viewport, offset: undefined }; + return { renderScale: input.renderScale, viewport }; } // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels. const page = await readPageMetrics(input.cdp); return { renderScale: page.ratio / page.zoom, - offset: page.offset, viewport: { width: Math.round(page.width * page.zoom), height: Math.round(page.height * page.zoom), @@ -544,7 +525,7 @@ const capture = async ( input: SnapshotInput, deadline: number, ): Promise => { - const { renderScale, viewport, offset } = await renderingOf(input); + const { renderScale, viewport } = await renderingOf(input); // A late metrics reply must not begin a capture after viewers have resumed. if (Date.now() >= deadline) throw tooBusy(); const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); @@ -553,8 +534,7 @@ const capture = async ( const generation = state.generation; const { readers, read } = await openReaders(input.page, input.cdp); const before = await read(); - // Taken between the reads, so a value that changes during either fails the comparison. - // Awaited after the tree: a capture that fails has finished its tree before a retry starts. + // Bracket the tree with value reads to detect changes; await tree completion before retrying failed captures. const pageAndScreenshot = Promise.all([ input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< Pick< @@ -562,7 +542,9 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + captureViewport(input.cdp, { format: "png" }).then((png) => + scale < 1 ? shrinkPng(input.cdp, png, scale) : png, + ), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. @@ -576,8 +558,7 @@ const capture = async ( if (after.some((values, i) => values && JSON.stringify(values) !== JSON.stringify(before[i]))) throw pageChanged(); const dotted = dottedPattern(after.flatMap((values) => values ?? [])); - // A frame that didn't answer both times, such as one that crashed, can only hold values the - // tree shows if the tree shows its content. + // Unread frames can expose values only if their content appears in the tree. const unread = readers.flatMap((reader, i) => (after[i] ? [] : reader.frames)); const filled = /^\s*- iframe (?:\[active\] )?\[ref=(\S+?)\].*:$/gm; for (const [, ref] of unread.length ? tree.matchAll(filled) : []) { @@ -602,8 +583,7 @@ const capture = async ( }); return { ...page, - // Text that holds a value would otherwise show where the tree masked it. Masked before - // the cut, so a value crossing it can't leave its start showing. + // Mask text before truncating so a value crossing the cut cannot leak its prefix. visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH), accessibilityTree, consoleEntries: [...input.consoleEntries], @@ -628,9 +608,7 @@ export const snapshot = async (input: SnapshotInput): Promise Date.now() < deadline && snapshotTurns.get(input.page) === turn && @@ -647,10 +625,7 @@ export const snapshot = async (input: SnapshotInput): Promise { (locator !== null || (await target.count()) > 0) && (await target.evaluate( (element) => { - // Like the desktop host: an enabled text control or contenteditable - // that actually takes focus. Anything else would swallow the text or - // send it to whichever field had focus before. + // Require an enabled, focused text target to avoid losing input or sending it to the previous field. const control = element as unknown as { readonly type?: string; readonly disabled?: boolean; @@ -799,8 +772,7 @@ export const drag = async ( const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); - // Observe the drag's outcome as it starts: a rejection nobody handles while the cursor - // moves exits the server. + // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. const dragFailure = source.dragTo(target, { timeout }).then( () => null, (error: unknown) => ({ error }), @@ -833,11 +805,7 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; -/** - * Runs an agent's expression, bounded by its request's deadline. Past it, the - * page's script is terminated and the call fails, so the tab's control queue - * frees for the next request instead of waiting on the script. - */ +/** Terminate expired page scripts so a hung expression cannot block the tab's control queue. */ export const evaluate = async ( cdp: CDPSession, input: PreviewAutomationEvaluateInput, diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 6b2d24802d247..d3f4781829a85 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -133,8 +133,7 @@ it.effect.each([ received.resolve(); }); yield* Effect.promise(() => received.promise); - // The ack follows every mutation on the socket, so this is also a barrier - // proving all preceding inputs were processed, without a timing sleep. + // The socket ack follows every mutation, providing a barrier without sleeps. yield* Effect.promise(() => acked.promise); expect(frames).toEqual([frame]); expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]); @@ -215,7 +214,6 @@ it.effect("sends replies ahead of stale frames and only the newest frame after a ]); yield* Effect.promise(() => next.promise); expect(received).toEqual([1, "probe"]); - // Frames 2 and 3 were superseded before they could be sent. expect(released).toEqual([2, 3]); next = Promise.withResolvers(); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index ad42eaf5d729d..8d8164cd911c4 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -26,7 +26,6 @@ import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; import * as ServerBrowser from "./ServerBrowser.ts"; const PREVIEW_STREAM_ROUTE_PREFIX = "/api/preview-stream"; -/** Matches `PREVIEW_STREAM_TAB_GONE_CODE` in the client. */ const TAB_GONE_CODE = 4404; const DEFAULT_QUALITY = 70; @@ -176,12 +175,13 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr } }), ); - const receive = (chunk: Uint8Array | string) => { + // Incoming messages share the session span; frame acknowledgements are frequent. + const receive = Effect.fnUntraced(function* (chunk: Uint8Array | string) { const message = parseMessage(chunk); // Ownership serializes actions in the service. Keep reading so dialog // replies and takeover can unblock an action already waiting on the page. if (!isAck(message)) - return canOperate + return yield* canOperate ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) : Effect.void; const ack = inFlight; @@ -189,11 +189,11 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr inFlight = null; pending = null; // Forked: Chromium acks are paced and must not hold up input. - return Effect.andThen( + return yield* Effect.andThen( ack ? Effect.forkScoped(ack) : Effect.void, next ? sendFrame(next) : Effect.void, ); - }; + }); const receiveInput = reader.pull.pipe( Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); @@ -206,7 +206,7 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), ), }), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), + Effect.orElseSucceed(() => HttpServerResponse.empty()), ); }); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cee60e2f95321..de02f32a1f774 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -110,10 +110,7 @@ export class SessionControl { return this.agentMayAct(agentId) && this.owner === null; } - /** - * A read that needs no control and does not queue, so an agent can look at - * a page while a human drives it without holding up the human's input. - */ + /** Read without control or queueing so agents can observe without delaying human input. */ observe(run: () => Promise) { this.assertOpen(); return run(); diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index 44e9d6dbf4c2a..94ca314ac1f33 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -227,6 +227,24 @@ describe("AgentScope service", () => { ), ); + it.effect("delegates every agent scope so rootless containers can manage their cgroups", () => + withFakeSystemd(({ scope }) => + Effect.gen(function* () { + const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const codex = yield* launchCodex(scope, "a"); + const terminal = yield* scope.wrap({ + command: "agent", + args: [], + name: "terminal", + survivesRestart: true, + }); + for (const launch of [agent, codex, terminal]) { + expect(launch.args).toContain("--property=Delegate=yes"); + } + }), + ), + ); + 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 d923a1bc7e9ed..cc6d3a04121f6 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -48,11 +48,7 @@ export interface ScopeState { readonly populated: boolean | undefined; } -/** - * Our pipe can close before systemd handles the kill, so a scope that is - * still stopping, is empty, or already counted an OOM kill is "stopping": - * systemd records `Result=oom-kill` for it in a moment. - */ +/** Pipes can close before systemd records oom-kill; stopping, empty or OOM-counted scopes remain pending. */ export function classifyScope(state: ScopeState): "oom-killed" | "gone" | "running" | "stopping" { if (state.result === "oom-kill") return "oom-killed"; if ( @@ -86,9 +82,9 @@ function parseCgroupCounter(text: string, key: string): number | undefined { return match === null ? undefined : Number(match[1]); } -// A stop sends SIGTERM, then SIGKILL after this long. systemd's 90 s default -// would outlast the wait for a previous server's agents below. -const STOP_TIMEOUT_OPTION = "--property=TimeoutStopSec=10s"; +// systemd's default 90 s stop timeout would outlast our leftover-agent wait. +// Delegation lets rootless containers (Podman, kind) manage their own cgroups. +const SCOPE_OPTIONS = ["--property=TimeoutStopSec=10s", "--property=Delegate=yes"]; const STOPPING_POLL_INTERVAL = "100 millis"; const STOPPING_POLL_ATTEMPTS = 20; const CLEAR_FAILED_INTERVAL = "1 second"; @@ -172,8 +168,8 @@ export const make = Effect.gen(function* () { "true", ]).pipe(Effect.map((result) => result._tag === "Some" && result.value.code === 0)); // Scopes take OOMPolicy from systemd 253; older managers still get scopes. - const supportsOomPolicy = yield* probe([STOP_TIMEOUT_OPTION, "--property=OOMPolicy=stop"]); - if (!supportsOomPolicy && !(yield* probe([STOP_TIMEOUT_OPTION]))) { + const supportsOomPolicy = yield* probe([...SCOPE_OPTIONS, "--property=OOMPolicy=stop"]); + if (!supportsOomPolicy && !(yield* probe(SCOPE_OPTIONS))) { yield* Effect.logInfo("Agent scopes are off: no systemd user manager"); return undefined; } @@ -364,7 +360,7 @@ export const make = Effect.gen(function* () { // Without an owning thread nothing reads the result, so let systemd // unload the unit even when it fails. options: [ - STOP_TIMEOUT_OPTION, + ...SCOPE_OPTIONS, ...(scope.supportsOomPolicy ? [`--property=OOMPolicy=${continuing ? "continue" : "stop"}`] : []), diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 73ff1b067e875..1e816f31b77d5 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -19,10 +19,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -// Tests below write `#!/bin/sh` stubs into a real temp dir and hand that -// directory to a posix-mocked resolver as PATH. On a Windows host the temp -// path carries a drive letter, so the posix `:` split shatters it; there is -// no posix executable to find there anyway. +// Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; interface MockSpawnResult { @@ -183,17 +180,6 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -for (const {} of ["darwin", "linux"] as const) { -} - -for (const {} of [ - { kind: "browser", folderVerb: "none" }, - { kind: "reveal", folderVerb: "none" }, - { kind: "reveal", folderVerb: "" }, - { kind: "reveal", folderVerb: "open" }, -] as const) { -} - it.effect.skipIf(windowsHost)( "reveals a WSL file in Windows File Explorer through its UNC path", () => @@ -308,91 +294,6 @@ for (const wsl of [false, true]) { ); } -for (const {} of ["rejected", "spawn-error", "stalled"] as const) { -} - -for (const {} of [ - { - platform: "darwin", - installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide", - editor: "antigravity", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "linux", - installPath: ".local/bin/antigravity-ide", - editor: "antigravity", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/Cursor.app/Contents/Resources/app/bin/code", - editor: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/Kiro.app/Contents/Resources/app/bin/code", - editor: "kiro", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "darwin", - installPath: "Applications/Zed.app/Contents/MacOS/cli", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, - { - platform: "win32", - installPath: "Programs/Cursor/resources/app/bin/cursor.cmd", - editor: "cursor", - args: ['^"--classic^"', '^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'], - }, - { - platform: "win32", - installPath: "Programs/Microsoft VS Code/bin/code.cmd", - editor: "vscode", - args: ['^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'], - }, - { - platform: "win32", - installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "win32", - installPath: "Programs/WebStorm/bin/webstorm64.exe", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "win32", - installPath: "Programs/Zed/bin/zed.exe", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, - { - platform: "linux", - installPath: ".local/share/JetBrains/Toolbox/scripts/idea", - editor: "idea", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "linux", - installPath: ".local/bin/zed", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, -] as const) { -} - for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) { it.effect.skipIf(windowsHost)( `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`, diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 1260f2e3fda80..b8c0911a520d2 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -66,13 +66,7 @@ interface TargetPathAndPosition { readonly column: Option.Option; } -/** - * Windows command shims (`code.cmd`) forward arguments through `%*`, which - * cmd.exe parses a second time after the first escaping layer is gone: a - * line break ends the command there, and a double quote closes the quoting - * that keeps `&` or `|` literal. Neither can occur in a Windows path. - */ -// oxlint-disable-next-line no-control-regex +// cmd shims reparse %* after escaping is gone; newlines and quotes can expose shell operators. const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ @@ -83,9 +77,7 @@ const POWERSHELL_ARGUMENTS_PREFIX = [ "-EncodedCommand", ] as const; -// Windows PowerShell started with DETACHED_PROCESS exits 0 without running its -// command, so native Windows launches through it stay attached. WSL interop -// PowerShell starts from a Linux process and keeps the detached launch. +// Detached Windows PowerShell exits 0 without executing; only WSL interop keeps detached launches. const WINDOWS_POWERSHELL_DETACHED = false; const DETACHED_IGNORE_STDIO_OPTIONS = { @@ -196,10 +188,7 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -// File reveals from WSL resolve PowerShell through the interop PATH rather -// than the fixed /mnt/c mount: the automount root is configurable, and a -// PATH-resolved command keeps the advertised capability aligned with the -// availability check `launchEditor` performs before spawning. +// WSL automount roots vary; resolve PowerShell via PATH to match launchEditor's availability check. const WSL_POWERSHELL_COMMAND = "powershell.exe"; function shouldUseWindowsHostFromWsl( @@ -259,14 +248,8 @@ function fileManagerCommandForPlatform( } } -// A graphical session variable plus an executable `xdg-open` does not prove -// that opening a directory does anything: without an `inode/directory` MIME -// handler, `xdg-open` exits nonzero after the launcher has already detached, so -// the client would see a silent no-op. The probe carries its own timeout well -// inside the scan timeout `server.getConfig` applies to editor discovery: that -// outer timeout degrades to an empty editor list, so a hung `xdg-mime` (broken -// D-Bus or desktop session) must cost only the file manager, not every -// discovered editor. +// xdg-open needs an inode/directory handler or detached launches silently fail. +// Bound xdg-mime below discovery's timeout so a hung probe drops only the file manager. const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( @@ -319,10 +302,7 @@ const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManag }, ); -// WSL hosts prefer the Windows Explorer bridge, but interop can exist without -// `explorer.exe` on PATH (appendWindowsPath=false) or without a distro name -// while WSLg still provides a working Linux file manager, so they keep the -// `xdg-open` fallback instead of losing the editor entirely. +// WSL may lack Explorer on PATH or a distro name while WSLg still offers a Linux file manager. const resolveUsableFileManagerCommand = Effect.fn( "externalLauncher.resolveUsableFileManagerCommand", )(function* ( @@ -347,13 +327,8 @@ const resolveUsableFileManagerCommand = Effect.fn( return undefined; }); -// Reveal on Windows and WSL runs through PowerShell (see -// resolveFileManagerRevealLaunch), not the `explorer` command that gates the -// file-manager editor itself, so the capability must probe the executables the -// reveal actually spawns. Callers gate on file-manager availability first; -// the Linux "files" kind relies on that gate for the directory-handler probe, -// while the WSL fallback re-probes because its availability may have come -// from the Explorer bridge instead. +// Reveal uses PowerShell on Windows/WSL, so probe it separately from Explorer availability. +// Linux relies on the file-manager gate's handler probe; WSL re-probes because the gate may use Explorer. const fileManagerRevealKindForPlatform = Effect.fn( "externalLauncher.fileManagerRevealKindForPlatform", )(function* ( @@ -471,13 +446,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Callers apply a timeout (`resolveAvailableEditorsForConfig`) and disconnect -// mid-connect; neither may cancel a scan other connects are waiting on, or -// throw away work a slow host (a busy server at startup, a long PATH) needs -// more than one connect to finish. Expiry uses the monotonic clock -// (Clock.monotonicTimeNanos), matching the command-resolution cache in -// @t3tools/shared/shell, so a backward wall-clock adjustment cannot keep an -// expired entry alive. +// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { @@ -489,11 +458,7 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; - /** - * Only meaningful when `resolveAvailableEditors` includes "file-manager": - * on Linux that availability check also carries the directory-handler probe - * this capability relies on. - */ + /** Requires file-manager in resolveAvailableEditors; Linux relies on its directory-handler probe. */ readonly resolveFileManagerRevealKind: () => Effect.Effect; readonly launchBrowser: (target: string) => Effect.Effect; readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; @@ -556,8 +521,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] : platform === "win32" - ? // Explorer reads `/` as a switch, so `C:/repo` silently opens - // a default folder instead of the target. + ? // Explorer interprets / as a switch and silently opens a fallback folder. [normalizeWindowsFileManagerPath(input.cwd)] : [input.cwd], }; @@ -713,9 +677,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // The desktop app runs this server with ELECTRON_RUN_AS_NODE. A launched file - // manager or browser passes its environment on to everything started from it, - // so any Electron app opened from there would start as plain Node and exit. + // Inherited ELECTRON_RUN_AS_NODE would make Electron apps opened by this launcher run as Node and exit. const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, @@ -789,7 +751,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -827,8 +789,7 @@ export const make = Effect.gen(function* () { Effect.interruptible, Effect.forkIn(scope), ); - // Claiming the cache entry and starting its scan must not be split by an - // interrupt, or the entry would wait on a scan that never runs. + // Claim and start atomically; interruption between them would leave an entry waiting on no scan. const acquireEditorDiscovery = Effect.gen(function* () { const nowNanos = yield* Clock.monotonicTimeNanos; const [scan, isNewScan] = yield* Ref.modify( @@ -856,20 +817,29 @@ export const make = Effect.gen(function* () { }).pipe(Effect.uninterruptible); const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ - resolveAvailableEditors: () => cachedAvailableEditors, - resolveFileManagerRevealKind: () => - provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchBrowser: (target) => - launchBrowser(target).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchEditor: (input) => - provideCommandResolutionServices(launchEditor(input)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveAvailableEditors: Effect.fnUntraced(function* () { + return yield* cachedAvailableEditors; + }), + resolveFileManagerRevealKind: Effect.fnUntraced( + function* () { + return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchBrowser: Effect.fnUntraced( + function* (target) { + return yield* launchBrowser(target); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchEditor: Effect.fnUntraced( + function* (input) { + return yield* provideCommandResolutionServices(launchEditor(input)); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), }); }); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 29d6dbd836597..3754f25a4c775 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,3 +1,4 @@ +import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -119,9 +120,7 @@ describe("runProcess", () => { it.effect("types spawn failures that the platform spawner throws synchronously", () => Effect.gen(function* () { - // A non-directory entry on PATH makes node's spawn throw ENOTDIR - // synchronously. The platform spawner calls it inside `Effect.callback`, - // so the throw arrives as a defect rather than a typed error. + // File-valued PATH entries make spawn throw ENOTDIR synchronously, arriving through Effect.callback as a defect. const defect = Object.assign(new Error("spawn jj ENOTDIR"), { code: "ENOTDIR" }); const spawner = makeSpawner(() => Effect.callback(() => { @@ -262,15 +261,12 @@ describe("runProcess", () => { expect(result.code).toBe(0); }), ); - - for (const {} of ["timeout", "success"] as const) { - } }); describe("isWindowsCommandNotFound", () => { it.effect("matches the localized German cmd.exe error text", () => Effect.gen(function* () { - const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound( + const isCommandNotFound = yield* isWindowsCommandNotFound( 1, "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", ).pipe(Effect.provideService(HostProcess.Platform, "win32")); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 05640ec968d34..a345c67eca881 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -29,10 +29,7 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - /** - * On timeout, return a synthetic timedOut result. - * Partial stdout/stderr are not preserved. - */ + /** Timed-out results discard partial stdout/stderr. */ readonly timeoutBehavior?: "error" | "timedOutResult" | undefined; } @@ -150,8 +147,6 @@ const DEFAULT_TIMEOUT = "60 seconds"; const FORCE_KILL_AFTER = "1 second"; const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -export { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshotProbe"; - const collectText = Effect.fnUntraced(function* (input: { readonly command: string; readonly args: ReadonlyArray; @@ -311,9 +306,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( ) .pipe( Effect.mapError(toSpawnError), - // Node's spawn can also throw synchronously, e.g. ENOTDIR when a PATH - // entry is a file. That arrives as a defect, which `mapError` never sees, - // so callers that recover from spawn failures would be bypassed. + // Synchronous spawn throws arrive as defects; recover them too so callers see typed spawn failures. Effect.catchDefect((cause) => Effect.fail(toSpawnError(cause))), ); @@ -393,7 +386,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index be7457da4335d..69026f91202af 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -209,18 +209,16 @@ const makeImporter = Effect.fn(function* ( }), Layer.mock(ProjectService.ProjectService)({ getById: () => - Effect.succeed( - Option.some({ - id: projectId, - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - createdAt: timestamp, - updatedAt: timestamp, - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }), }), Layer.mock(Orchestrator.OrchestratorV2)({ getThreadRecords: (threadId, fields) => diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index f9fe7e9754197..84b318f0642cc 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -47,9 +47,7 @@ export function createTranscriptJsonReader( selectPath: (path: JsonPath) => boolean, options?: { readonly maxDepth?: number }, ) { - // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted - // from its bundled types. Unlike parser(), it does not wrap tokens in an - // async generator; the file reader already supplies backpressure and UTF-8. + // 3.6.0 omits the synchronous tokenizer from types; parser() adds an unnecessary async generator. const { jsonParser } = StreamJson as typeof StreamJson & { jsonParser: ( options: ParserOptions, @@ -88,9 +86,7 @@ export function createTranscriptJsonReader( assembler.consume(token); } }; - // Forward actual selected keys instead of reconstructing them from path - // changes: adjacent duplicate keys have the same path but JSON.parse keeps - // the last value. Reconstructing paths can silently retain the first value. + // Preserve actual keys; adjacent duplicates share a path, but JSON.parse keeps the last value. const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; let selectedValue = false; const startValue = () => { diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 24a81a1b143da..9c26b187a55bf 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -53,11 +53,7 @@ const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; -/** - * Large tool results (especially screenshots) can make an otherwise ordinary - * Codex transcript several GiB. Streaming field selection avoids allocating - * those payloads. - */ +// Screenshot-heavy transcripts can reach GiB; select fields while streaming to avoid allocating payloads. const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORTED_MESSAGES = 200; const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; @@ -438,9 +434,7 @@ function parseAgentSessionRecords( if (record.type === "event_msg" && record.payload?.type === "user_message") { const text = record.payload.message ?? ""; if (text.trim().length === 0) continue; - // Codex can write the same prompt as both a response item and an event. - // Remove only the matching response copy so mixed-format logs keep every - // distinct user message. + // Codex can duplicate prompts across formats; remove only matching response copies. for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === "assistant") break; @@ -626,11 +620,10 @@ function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdent ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - // Different project imports can arrive concurrently from multiple clients. - // Only one transcript may hold its selected-history budget at a time. + // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -1222,8 +1215,7 @@ export const make = Effect.gen(function* () { const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); - // A symlink can point into the worktrees directory even when its own - // spelling doesn't; check again with links resolved. + // Symlinks can disguise excluded worktree paths; re-check their targets. if (isExcludedProjectPath(realPath)) { key = ""; } else { @@ -1260,8 +1252,7 @@ export const make = Effect.gen(function* () { : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); } - // Resolve persisted roots too. A project and a transcript can name - // different symlinks to the same directory. + // Resolve persisted roots too; transcripts may use different symlinks to the same directory. const importedProjects = yield* projectStore .listShells() .pipe( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 449d7b8e00fc8..df6cb6f6b507e 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -98,10 +98,7 @@ export class ManagedProjectFolders extends Context.Service< readonly text: string; }) => Effect.Effect, ScratchFolderError>; readonly namedProjectsRoot: string; - /** - * The folder is removed when the create fails or is cancelled before the - * project exists, never once another project owns it. - */ + /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< { readonly projectId: ProjectId; @@ -151,10 +148,7 @@ function escapeXml(value: string): string { .replaceAll('"', """); } -/** - * It lives at `assets/icon.svg`, a path ProjectFaviconResolver already checks, - * so every machine that clones the project shows the same icon. - */ +// ProjectFaviconResolver finds assets/icon.svg on every clone. function namedProjectIconSvg(name: string): string { const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); const initials = @@ -201,7 +195,6 @@ function describeCommitFailure(stderr: string): string { const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError); -// Spawning a `git` that is not on PATH fails with NotFound, on Windows too. const isGitMissing = (error: GitManagerServiceError) => error._tag === "GitManagerError" && isVcsProcessSpawnError(error.cause) && @@ -217,10 +210,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const crypto = yield* Crypto.Crypto; - /** - * Each folder is created without `recursive`, so creating it is the claim: of - * two racers for one name, one gets AlreadyExists and moves on to the next. - */ + // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( folderFor: (attempt: number) => Effect.Effect, E>, onError: (folder: string, cause: PlatformError.PlatformError) => E, @@ -240,9 +230,7 @@ const make = Effect.gen(function* () { } }); - // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would - // inherit the repo's git status and checkpoints, so Scratch is offered only - // when the data dir is outside any work tree. + // Scratch inside a checkout would inherit its git status and checkpoints; require a data dir outside work trees. const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL( gitWorkflow.isRepository(config.baseDir).pipe( Effect.catchIf(isGitMissing, () => Effect.succeed(false)), @@ -284,8 +272,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // bootstrap looks the root up before taking the workspace lock, so a - // racing create loses with a conflict that names the winner. + // bootstrap reads before locking; racing creates return the winner in a conflict. Effect.catchTags({ ProjectConflictError: (conflict) => Effect.succeed({ @@ -343,9 +330,9 @@ const make = Effect.gen(function* () { return yield* claimFreeFolder( (attempt) => attempt === 1 - ? Effect.succeed(Option.some(folderFor(id.slice(-8)))) + ? Effect.succeedSome(folderFor(id.slice(-8))) : attempt === 2 - ? Effect.succeed(Option.some(fullFolder)) + ? Effect.succeedSome(fullFolder) : crypto.randomUUIDv4().pipe( Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)), Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })), @@ -408,7 +395,7 @@ const make = Effect.gen(function* () { path.join(cwd, "assets", "icon.svg"), namedProjectIconSvg(name), ); - // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + // Force explicit filenames so global ignore rules cannot drop them. yield* git.execute({ operation: "ManagedProjectFolders.add", cwd, @@ -453,11 +440,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // A rejected create leaves the folder unused, so remove it. An - // interrupt can land after the create is committed, and a conflict - // means another project owns the folder, so it stays then; the owner - // is checked again first, since deleting another project's files is - // never acceptable. + // Re-check ownership before deleting rejected creations; interruption after commit or conflicts can leave an owner. Effect.tapError((error) => error._tag === "ProjectConflictError" ? Effect.void diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 411d84fb38592..4a0c99795155c 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -44,10 +44,7 @@ export class ProjectCloneTracker extends Context.Service< /** Interrupts a running clone and deletes the partial checkout. */ readonly cancel: (projectId: ProjectId) => Effect.Effect; readonly retry: (projectId: ProjectId) => Effect.Effect; - /** - * Stops tracking a project's clone, interrupting it if it still runs and - * removing an unfinished checkout. - */ + /** Stops tracking, interrupts any running clone and removes unfinished checkouts. */ readonly discard: (projectId: ProjectId) => Effect.Effect; readonly get: (projectId: ProjectId) => Effect.Effect; readonly stream: Stream.Stream>; @@ -91,7 +88,7 @@ interface TrackedClone { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const repositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const clones = yield* Ref.make(new Map()); @@ -100,8 +97,7 @@ export const make = Effect.gen(function* () { let sequence = 0; const cloneScope = yield* Scope.make("parallel"); yield* Effect.addFinalizer(() => Scope.close(cloneScope, Exit.void)); - // start/cancel/retry/discard mutate the same entry and the same directory; - // one at a time keeps a double-clicked Retry from racing two clones into it. + // Serialize actions on the shared entry and directory so retries cannot race clones. const actionLock = yield* Semaphore.make(1); const locked = (effect: Effect.Effect) => actionLock.withPermits(1)(effect); @@ -230,11 +226,8 @@ export const make = Effect.gen(function* () { }, Effect.scoped); /** - * Runs in its own fiber; the tracker records the outcome through `onExit`, - * which still runs when the fiber is interrupted (a `matchCause` handler - * would be skipped). Once git has finished the clone is marked done before - * the post-clone hook runs, so a late Cancel cannot tear down a complete - * checkout. + * `onExit` records outcomes even on interruption; `matchCause` would be skipped. + * Mark the clone done before the post-clone hook so a late Cancel cannot remove it. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => cloneWatchingForStall(projectId, tracked).pipe( @@ -313,14 +306,11 @@ export const make = Effect.gen(function* () { detail: "A clone into this destination is already in progress.", }); } - // Everything after the claim runs to completion even if the requesting - // connection drops: a claimed entry with no fiber could neither be - // cancelled nor retried. Any failure in here releases the claim. + // Finish or release the claim despite disconnects; a claim without a fiber cannot be cancelled or retried. yield* Effect.uninterruptible( Effect.gen(function* () { yield* clearRetention(input.projectId); - // The entry is registered before the project exists so a - // thread.create or project.delete racing this call already sees it. + // Register before creation so racing thread.create and project.delete see the clone. yield* hooks.createProject({ projectId: input.projectId, title: input.title, @@ -348,8 +338,7 @@ export const make = Effect.gen(function* () { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; - // Uninterruptible past this point: a client that disconnects mid-cancel - // must not leave a dead fiber behind a "running" snapshot. + // Finish cancellation despite disconnects to avoid dead fibers behind running snapshots. yield* Effect.uninterruptible( Effect.gen(function* () { yield* Fiber.interrupt(tracked.fiber!); @@ -428,11 +417,20 @@ export const make = Effect.gen(function* () { { bufferSize: 1, strategy: "sliding" }, ); + // Forwarders retain the underlying operation spans. return ProjectCloneTracker.of({ - start: (input, hooks) => locked(start(input, hooks)), - cancel: (projectId) => locked(cancel(projectId)), - retry: (projectId) => locked(retry(projectId)), - discard: (projectId) => locked(discard(projectId)), + start: Effect.fnUntraced(function* (input, hooks) { + return yield* locked(start(input, hooks)); + }), + cancel: Effect.fnUntraced(function* (projectId) { + return yield* locked(cancel(projectId)); + }), + retry: Effect.fnUntraced(function* (projectId) { + return yield* locked(retry(projectId)); + }), + discard: Effect.fnUntraced(function* (projectId) { + return yield* locked(discard(projectId)); + }), get, stream, }); diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 492d1c43419ab..6da9ae31203bf 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -25,12 +25,16 @@ const waitForAvailable = Effect.fn("ProjectEnrichmentServiceTest.waitForAvailabl workspaceRoot: string, predicate: (value: ProjectEnrichment.ProjectEnrichment) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const available = yield* service.peek(workspaceRoot); - if (predicate(available)) return available; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project metadata for ${workspaceRoot} was not resolved in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const changes = yield* service.subscribeChanges; + for (;;) { + const available = yield* service.peek(workspaceRoot); + if (predicate(available)) return available; + yield* PubSub.take(changes); + } + }), + ); }); const layer = ( diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 1592204816fec..f67ec6e53bb4d 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -19,9 +19,7 @@ const DEFAULT_CACHE_CAPACITY = 512; const DEFAULT_MAX_PENDING = 512; const DEFAULT_CONCURRENCY = 4; const DEFAULT_SUCCESS_TTL = Duration.minutes(1); -// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every -// linked project about once a minute, and icons rarely change, so a resolved favicon is kept -// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache. +// Frequent shell reads scan ~20 files per project; icons change rarely, so cache longer than identities. const DEFAULT_FAVICON_TTL = Duration.minutes(15); const DEFAULT_FAILURE_TTL = Duration.seconds(5); @@ -41,9 +39,6 @@ export interface ProjectEnrichmentServiceOptions { readonly cacheCapacity?: number; readonly maxPending?: number; readonly concurrency?: number; - readonly successTtl?: Duration.Input; - readonly faviconTtl?: Duration.Input; - readonly failureTtl?: Duration.Input; } export class ProjectEnrichmentService extends Context.Service< @@ -52,6 +47,9 @@ export class ProjectEnrichmentService extends Context.Service< readonly peek: (workspaceRoot: string) => Effect.Effect; readonly request: (workspaceRoot: string) => Effect.Effect; readonly getAvailable: (workspaceRoot: string) => Effect.Effect; + readonly readRepositoryIdentity: ( + workspaceRoot: string, + ) => Effect.Effect; readonly invalidate: (workspaceRoots: Iterable) => Effect.Effect; readonly subscribeChanges: Effect.Effect< PubSub.Subscription, @@ -92,17 +90,14 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( const cacheCapacity = Math.max(1, options.cacheCapacity ?? DEFAULT_CACHE_CAPACITY); const maxPending = Math.max(1, options.maxPending ?? DEFAULT_MAX_PENDING); const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); - const successTtl = options.successTtl ?? DEFAULT_SUCCESS_TTL; - const failureTtl = options.failureTtl ?? DEFAULT_FAILURE_TTL; - const faviconTtl = options.faviconTtl ?? DEFAULT_FAVICON_TTL; const repositoryIdentityCache = yield* Cache.makeWith( (workspaceRoot: string) => Effect.exit(repositoryIdentityResolver.resolve(workspaceRoot)), { capacity: cacheCapacity, timeToLive: Exit.match({ - onFailure: () => failureTtl, - onSuccess: (result) => (Exit.isSuccess(result) ? successTtl : failureTtl), + onFailure: () => DEFAULT_FAILURE_TTL, + onSuccess: (result) => (Exit.isSuccess(result) ? DEFAULT_SUCCESS_TTL : DEFAULT_FAILURE_TTL), }), }, ); @@ -111,8 +106,8 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( { capacity: cacheCapacity, timeToLive: Exit.match({ - onFailure: () => failureTtl, - onSuccess: (result) => (Exit.isSuccess(result) ? faviconTtl : failureTtl), + onFailure: () => DEFAULT_FAILURE_TTL, + onSuccess: (result) => (Exit.isSuccess(result) ? DEFAULT_FAVICON_TTL : DEFAULT_FAILURE_TTL), }), }, ); @@ -273,6 +268,12 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( return available; }); + const readRepositoryIdentity: ProjectEnrichmentService["Service"]["readRepositoryIdentity"] = + Effect.fn("ProjectEnrichmentService.readRepositoryIdentity")(function* (workspaceRoot) { + const resolved = yield* Cache.get(repositoryIdentityCache, workspaceRoot); + return Exit.isSuccess(resolved) ? resolved.value : null; + }); + const invalidate: ProjectEnrichmentService["Service"]["invalidate"] = Effect.fn( "ProjectEnrichmentService.invalidate", )(function* (workspaceRoots) { @@ -296,6 +297,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( peek, request, getAvailable, + readRepositoryIdentity, invalidate, subscribeChanges: PubSub.subscribe(changes), }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2d18d0cbeeeea..c5aa691232574 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -78,9 +78,7 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; -// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; @@ -135,7 +133,7 @@ function extractIconHref(source: string): string | null { return null; } -const optionOnNotFound = ( +export const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( @@ -276,11 +274,8 @@ export const make = Effect.gen(function* () { return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); }); - // Starts every probe at once and returns the first hit in list order. A miss - // costs one round of filesystem latency instead of one per probe, which - // matters when the disk is slow. Fibers are joined in order, so a hit returns - // without waiting for lower-ranked probes (the scope interrupts them), and a - // failure surfaces only when no earlier probe found a file. + // Probe concurrently but join in list order so lower-ranked probes cannot delay a hit. + // A failure surfaces only if no earlier probe found a file; scope exit interrupts remaining probes. const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* ( items: ReadonlyArray, probe: (item: A) => Effect.Effect, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 9347acef12c8f..331332a84bc95 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,3 +1,6 @@ +import * as Context from "effect/Context"; +import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; @@ -5,12 +8,17 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -51,6 +59,35 @@ const layerMetadata = Layer.merge( }), ); +class EnrichmentCompletions extends Context.Service>()( + "t3/project/ProjectService.test/EnrichmentCompletions", +) {} + +const layerEnrichmentCompletions = Layer.effectContext( + Effect.gen(function* () { + const completions = yield* Queue.unbounded(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if ( + span.name === "ProjectEnrichmentService.resolveRepositoryIdentity" || + span.name === "ProjectEnrichmentService.resolveFavicon" + ) { + Queue.offerUnsafe(completions, undefined); + } + }; + return span; + }, + }); + return Context.make(EnrichmentCompletions, completions).pipe( + Context.add(Tracer.Tracer, tracer), + ); + }), +); + const layerTestFor = ( projectMetadataLayer: Layer.Layer< | ProjectFaviconResolver.ProjectFaviconResolver @@ -65,6 +102,7 @@ const layerTestFor = ( Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), Layer.provide(NodeServices.layer), + Layer.provideMerge(layerEnrichmentCompletions), ); const layerTest = layerTestFor(layerMetadata); @@ -91,12 +129,16 @@ const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* projectId: ProjectId, predicate: (project: Project) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const project = Option.getOrThrow(yield* service.getById(projectId)); - if (predicate(project)) return project; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project ${projectId} was not enriched in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const completions = yield* EnrichmentCompletions; + for (;;) { + const project = Option.getOrThrow(yield* service.getById(projectId)); + if (predicate(project)) return project; + yield* Queue.take(completions); + } + }), + ); }); it.layer(layerTest)("ProjectService", (it) => { @@ -203,6 +245,33 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); + it.effect("records the outcome and duration of a committed project command", () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "project.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "project.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* service.create({ + commandId: CommandId.make("command:metrics:create"), + projectId: ProjectId.make("project:metrics"), + title: "Metrics", + workspaceRoot: "/work/metrics", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }), + ); + it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 1cfb2e093340b..18004510baa45 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -131,6 +132,17 @@ export class ProjectService extends Context.Service< options?: { readonly includeDeleted?: boolean }, ) => Effect.Effect, ProjectOperationError>; readonly snapshot: Effect.Effect; + /** + * A page of active projects in creation order, each with its repository + * identity resolved, so a caller can match projects by repository. + */ + readonly listPage: (input: { + readonly cursor: number; + readonly limit: number; + }) => Effect.Effect< + { readonly projects: ReadonlyArray; readonly nextCursor: number | null }, + ProjectOperationError + >; readonly getShell: ( projectId: ProjectId, ) => Effect.Effect, ProjectOperationError>; @@ -266,8 +278,7 @@ export const make = Effect.gen(function* () { `Command ${command.commandId} was already used by ${receipt.commandType} for ${receipt.projectId}.`, ); } - // A retried command re-plans against the state it already produced, so its - // first receipt, not the new plan, decides the outcome. + // Retries re-plan against their own effects; the first receipt determines the outcome. if (receipt.status === "accepted") return; const rejection = Option.getOrUndefined(decodeProjectCommandRejection(receipt.error)); switch (rejection?._tag) { @@ -284,7 +295,7 @@ export const make = Effect.gen(function* () { rejection ?? receipt.error ?? "The command was previously rejected.", ); } - }); + }, withCommandMetrics); const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) { const row = yield* readRow(projectId, { includeDeleted: true }); @@ -474,11 +485,7 @@ export const make = Effect.gen(function* () { ); }); - // A machine-disabled provider instance that a deleted project's override - // once turned on must not stay probed and alive forever. Nothing else - // removes the entry, so deletion is where it goes; best-effort because a - // settings write failure should not block the deletion it is cleaning up - // after, and a server crash before a replay never revisits this project. + // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion. const clearProjectSettingsOverrides = (projectId: ProjectId) => serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( Effect.asVoid, @@ -493,8 +500,7 @@ export const make = Effect.gen(function* () { const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( function* (input) { const { projectId } = input; - // A deleted row still reaches commit, so a retried command id replays its - // receipt and any other command id is rejected as not found. + // Read deleted rows so retries replay receipts and other command ids fail as not found. const existing = yield* readRow(projectId, { includeDeleted: true }); if (Option.isNone(existing)) { return yield* new ProjectNotFoundError({ projectId }); @@ -562,6 +568,23 @@ export const make = Effect.gen(function* () { } satisfies ProjectSnapshot; }); + const listPage: ProjectService["Service"]["listPage"] = Effect.fn("ProjectService.listPage")( + function* ({ cursor, limit }) { + const rows = (yield* snapshot).projects.filter((project) => project.deletedAt === null); + const projects = yield* Effect.forEach( + rows.slice(cursor, cursor + limit), + (project) => + project.repositoryIdentity !== null + ? Effect.succeed(project) + : projectEnrichment + .readRepositoryIdentity(project.workspaceRoot) + .pipe(Effect.map((repositoryIdentity) => ({ ...project, repositoryIdentity }))), + { concurrency: 8 }, + ); + return { projects, nextCursor: cursor + limit < rows.length ? cursor + limit : null }; + }, + ); + return ProjectService.of({ create, bootstrap, @@ -570,6 +593,7 @@ export const make = Effect.gen(function* () { getById, getByWorkspaceRoot, snapshot, + listPage, getShell, listShells, }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000000..29a15a6f0715b --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import { TestClock } from "effect/testing"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; + +const threadId = "thread-1"; +const terminalId = "setup-setup"; +const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; + +const openSnapshot = (history: string): TerminalSessionSnapshot => ({ + threadId, + terminalId, + cwd: "/repo/.t3/worktrees/feature", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const makeHarness = Effect.fn(function* (options: { + readonly history?: string; + readonly opening?: Deferred.Deferred; +}) { + const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const writes = yield* Queue.unbounded(); + const outputLines = yield* Queue.unbounded(); + const terminals = Layer.mock(TerminalManager.TerminalManager)({ + open: () => + (options.opening + ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void + ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + subscribe: (listener) => + Effect.sync(() => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), + closeIdle: () => Effect.void, + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + terminals, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThreadShell: () => Effect.succeed(null), + }), + ServerSettings.layerTest(), + NodeCrypto.layer, + ), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), + ); + const start = runner.runForThread({ + threadId, + worktreePath: "/repo/.t3/worktrees/feature", + observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) }, + project: { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }, + }); + const emit = (terminalEvent: TerminalEvent) => + Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); + return { start, listeners, writes, outputLines, emit }; +}); + +const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { + const harness = yield* makeHarness(options); + const result = yield* harness.start; + const completion = result.status === "started" ? result.completion : undefined; + if (!completion) return yield* Effect.die("setup did not start an observed run"); + return { ...harness, result, completion }; +}); + +describe("ProjectSetupScriptRunner", () => { + it.effect("shows a startup prompt and types only once the line editor starts", () => + Effect.gen(function* () { + const { result, writes, outputLines, emit } = yield* setup(); + expect(result).toMatchObject({ status: "started", terminalId }); + + yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]); + + yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); + yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("types into a quiet shell that has no line editor", () => + Effect.gen(function* () { + const { writes } = yield* setup(); + yield* TestClock.adjust("3 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("ignores line editor state restored from an earlier shell", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); + + it.effect("forgets a busy startup child when the shell restarts", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup(); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); + // The restart lands inside a quiet window, so the next full window decides. + yield* TestClock.adjust("6 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("drops its terminal listeners when cancelled while the terminal opens", () => + Effect.gen(function* () { + const opening = yield* Deferred.make(); + const { start, listeners } = yield* makeHarness({ opening }); + const fiber = yield* Effect.forkChild(start); + yield* Deferred.await(opening); + expect(listeners.size).toBe(2); + yield* Fiber.interrupt(fiber); + expect(listeners.size).toBe(0); + }), + ); + + it.effect("does not type into a shell that exits during startup", () => + Effect.gen(function* () { + const { completion, writes, emit } = yield* setup(); + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 6799be186d1a4..095f20938c8b0 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,4 +1,4 @@ -import { ProjectId, type ProjectScript } from "@t3tools/contracts"; +import { linkOriginIsFenced, ProjectId, type ProjectScript, ThreadId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { projectScriptRuntimeEnv, @@ -17,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectService from "./ProjectService.ts"; @@ -33,9 +34,7 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly terminalId: string; readonly cwd: string; readonly async: boolean; - /** - * The exit code is null when the terminal exited or was closed before the sentinel arrived. - */ + /** Null exit code means the terminal exited or closed before the sentinel. */ readonly completion?: Effect.Effect; } @@ -48,8 +47,18 @@ export interface ProjectSetupScriptOutputLine { readonly line: string; } +/** + * A thread a link below full access started does not run this project's setup + * script: the script is the user's own code, and such a link only grants T3's + * tools (see `mcp/linkOrigin.ts`). + */ +export interface ProjectSetupScriptRunnerResultSkippedForLink { + readonly status: "skipped-for-link"; +} + export type ProjectSetupScriptRunnerResult = | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultSkippedForLink | ProjectSetupScriptRunnerResultStarted; export interface ProjectSetupScriptRunnerInput { @@ -57,7 +66,6 @@ export interface ProjectSetupScriptRunnerInput { readonly projectId?: string; readonly projectCwd?: string; readonly worktreePath: string; - readonly preferredTerminalId?: string; readonly trigger?: "setup" | "settle"; readonly project?: { readonly id: ProjectId; @@ -76,7 +84,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError()("t3/project/ProjectSetupScriptRunner") {} -/** @public Service construction is part of the canonical Effect module API. */ -/** - * Each run gets its own random token so script output cannot spoof completion, and the sentinel - * pattern is built per run from it. - */ +/** @public */ +// Random per-run tokens prevent script output from spoofing completion. const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; const OUTPUT_LINE_MAX_LENGTH = 400; const PARTIAL_LINE_MAX_LENGTH = 4_096; @@ -132,16 +137,24 @@ function completionSentinelPattern(token: string): RegExp { } function stripTerminalControl(text: string): string { - return ( - text - .replace( - // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with ESC. - /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, - "", - ) - // eslint-disable-next-line no-control-regex -- removing control characters is the point. - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - ); + return text + .replace( + /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, + "", + ) + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); +} + +// Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command. +const BRACKETED_PASTE_ON = "\x1b[?2004h"; +const BRACKETED_PASTE_OFF = "\x1b[?2004l"; +// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first. +const SHELL_QUIET_FALLBACK = "3 seconds"; + +function lineEditorReading(text: string, previous: boolean): boolean { + const on = text.lastIndexOf(BRACKETED_PASTE_ON); + const off = text.lastIndexOf(BRACKETED_PASTE_OFF); + return on === off ? previous : on > off; } type CompletionShell = "posix" | "fish" | "powershell"; @@ -158,12 +171,7 @@ function resolveCompletionShell( return "posix"; } -/** - * The command runs inside a block and the block closes on its own line, so a trailing `# comment` - * or a heredoc terminator in the command cannot swallow the sentinel. The shell reads the whole - * block before running any of it, so a script that reads stdin cannot consume the sentinel line - * either. - */ +// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -180,12 +188,14 @@ function wrapCommandForCompletion( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; + const threads = yield* ProjectionStore.ProjectionStoreV2; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Effect.scope; const completionShell = resolveCompletionShell( yield* HostProcess.Platform, yield* HostProcess.Environment, @@ -203,8 +213,7 @@ export const make = Effect.gen(function* () { }) { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); - // The shell redraws its prompt just after the sentinel. Closing before - // that would read the redraw as new activity and keep an idle shell. + // Wait for the prompt redraw; closing earlier mistakes it for activity and preserves an idle shell. const promptReturned = yield* Deferred.make(); let lineBuffer = ""; let settled = false; @@ -246,13 +255,10 @@ export const make = Effect.gen(function* () { } if (event.type === "output") { lineBuffer += event.data; - // A bare carriage return is how installers redraw a progress line in - // place; each redraw becomes a short line of its own instead of being - // glued into one long one. + // Bare CR redraws progress in place; split redraws instead of growing one line. const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; - // A script that never prints a newline must not grow this forever. - // The sentinel is always on its own line, so keeping the tail is safe. + // Bound newline-free output; the sentinel has its own line, so retaining the tail is safe. if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); } @@ -277,7 +283,67 @@ export const make = Effect.gen(function* () { Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))), Effect.ensuring(Effect.sync(() => unsubscribe())), ); - return { completion, unsubscribe }; + // A startup prompt waits for input on a line that has no newline yet. + const flushPartialLine = Effect.suspend(() => { + const partial = lineBuffer; + lineBuffer = ""; + return handleLine(partial); + }); + const abandon = settle(null).pipe( + Effect.andThen(Deferred.succeed(promptReturned, undefined)), + ); + return { completion, unsubscribe, flushPartialLine, abandon }; + }, + ); + + // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the + // shell, which loses the command. Subscribe before the terminal opens so no activity is missed. + const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly onStartupPrompt: Effect.Effect; + }) { + const outcome = yield* Deferred.make<"ready" | "exited">(); + let reading = false; + let carry = ""; + let busy = false; + let activity = 0; + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + activity += 1; + // A new shell process starts idle; the manager resets its subprocess state without an event. + if (event.type === "started" || event.type === "restarted") { + busy = false; + reading = false; + carry = ""; + } + if (event.type === "activity") busy = event.hasRunningSubprocess; + if (event.type === "output") { + const text = carry + event.data; + carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); + reading = lineEditorReading(text, reading); + } + if (event.type === "exited" || event.type === "closed") { + return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid); + } + return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void; + }); + // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. + const quiet = Effect.gen(function* () { + while (true) { + const seen = activity; + yield* Effect.sleep(SHELL_QUIET_FALLBACK); + if (activity !== seen) continue; + if (!busy) return "ready" as const; + yield* input.onStartupPrompt; + } + }); + // Restored history can hold a previous shell's line editor state, so only live output counts. + const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet)); + return { wait, unsubscribe }; }, ); @@ -290,6 +356,19 @@ export const make = Effect.gen(function* () { ...(input.projectId === undefined ? {} : { projectId: input.projectId }), ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), }; + // A thread whose origin cannot be read might be a link's: run nothing. + const thread = yield* threads.getThreadShell(ThreadId.make(input.threadId)).pipe( + Effect.mapError( + (cause) => + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readThread", + cause, + }), + ), + ); + if (thread?.linkOrigin !== undefined && linkOriginIsFenced(thread.linkOrigin)) + return { status: "skipped-for-link" } as const; const suppliedProject = input.project; const projectById = suppliedProject ?? @@ -347,21 +426,18 @@ export const make = Effect.gen(function* () { } as const; } - // A thread settles again after it is resumed, and an earlier settle shell - // may still be busy; typing into it would feed its foreground program. + // Earlier settle shells may still run foreground programs; do not type resumed settles into them. const terminalId = - input.preferredTerminalId ?? - (trigger === "settle" + trigger === "settle" ? `settle-${script.id}-${(yield* crypto.randomUUIDv4().pipe(Effect.orDie)).slice(0, 8)}` - : `setup-${script.id}`); + : `setup-${script.id}`; const cwd = input.worktreePath; const env = { ...projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }), - // Setup can run before a client attaches. Truecolor probes in tools such - // as Vite+ wait for terminal replies that nobody can send at that point. + // Before client attachment, truecolor probes wait for terminal replies nobody can send. COLORTERM: "", }; const observe = input.observeCompletion; @@ -377,25 +453,7 @@ export const make = Effect.gen(function* () { ) : script.command; - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); - // Subscribe before writing so the sentinel cannot race past the listener. + // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken ? yield* observeTerminalCompletion({ @@ -407,25 +465,63 @@ export const make = Effect.gen(function* () { onOutputLine: observe.onOutputLine, }) : undefined; + // PowerShell has no line-editor signal; keep its immediate write. + const readiness = + completionShell === "powershell" + ? undefined + : yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); yield* terminalManager - .write({ + .open({ threadId: input.threadId, terminalId, - data: projectScriptTerminalInput(commandLine), + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( Effect.mapError( (cause) => new ProjectSetupScriptOperationError({ ...errorContext, - operation: "writeCommand", + operation: "openTerminal", cause, }), ), - Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness?.unsubscribe(); + }), + ), ); + // Return once the terminal exists so callers can show it while a startup prompt waits. + yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + Effect.flatMap((shell) => + shell === "exited" + ? Effect.void + : terminalManager.write({ + threadId: input.threadId, + terminalId, + data: projectScriptTerminalInput(commandLine), + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("project script command was not written", { + ...errorContext, + terminalId, + cause, + }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), + ), + Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.forkIn(serviceScope), + ); + const completion = observed?.completion.pipe( Effect.tap(({ exitCode }) => exitCode === 0 diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 6fc57297a40e0..7cd1cc82d6fec 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -35,8 +35,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); - // Native realpath, since git reports the long form of a directory the - // temp dir may name by its 8.3 short form on Windows. + // Native realpath matches git's long paths when Windows temp dirs use 8.3 names. const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); const resolvedCwd = NodeFS.realpathSync.native(cwd); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index f75e1624f2a2d..659ace3fd2b49 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -14,16 +14,12 @@ import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -// Background sweeps resolve every project each minute. A long TTL keeps them -// from spawning git each time. Clone, publish, and PR discovery (after a turn -// and before it saves links) resolve with `refresh: true`. +// Minute-by-minute sweeps need cached hits; clone, publish and PR discovery force refresh. const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); export interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; - readonly positiveCacheTtl?: Duration.Input; - readonly negativeCacheTtl?: Duration.Input; readonly refine?: ( identity: RepositoryIdentity, ) => Effect.Effect; @@ -39,8 +35,7 @@ export class RepositoryIdentityResolver extends Context.Service< } >()("t3/project/RepositoryIdentityResolver") {} -// Local remotes (GitButler's `gb-local` is `.`) name a path on this machine, -// not a repository, so unrelated repos would share one identity. +// Local paths such as GitButler's gb-local=. would give unrelated repos the same identity. function isLocalRemoteUrl(url: string): boolean { if (/^file:/i.test(url)) return true; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false; @@ -145,8 +140,7 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. const processRunner = yield* ProcessRunner.ProcessRunner; const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); - // git is a real executable on every platform — no cmd.exe shell mode, which - // would split paths containing spaces during cmd's re-tokenization. + // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", @@ -235,9 +229,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const timeToLive = (exit: Exit.Exit) => Exit.match(exit, { onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + value === null ? DEFAULT_NEGATIVE_CACHE_TTL : DEFAULT_POSITIVE_CACHE_TTL, onFailure: () => Duration.zero, }); diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index b4335cb5660ce..1cc008d07cc83 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -28,10 +28,7 @@ export class T3ProjectFileLoadError extends Schema.TaggedError Effect.Effect>; } >()("t3/project/T3ProjectFileLoader") {} @@ -46,7 +43,7 @@ const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts index 0bdf7af4d2b05..ff5580961d977 100644 --- a/apps/server/src/project/WorktreeSetupTracker.ts +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -56,10 +56,7 @@ export class WorktreeSetupTracker extends Context.Service< phase: "done" | "failed" | "cancelled", error?: string | null, ) => Effect.Effect; - /** - * Called right before the turn is dispatched so a late cancel cannot roll - * back a thread whose agent has already started. - */ + /** Call just before dispatch so late cancellation cannot roll back a started agent's thread. */ readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; readonly cancel: (threadId: ThreadId) => Effect.Effect; readonly get: (threadId: ThreadId) => Effect.Effect; @@ -103,8 +100,7 @@ export const make = Effect.gen(function* () { readonly snapshot: WorktreeSetupSnapshot | null; }>(); const retentionFibers = new Map>(); - // Sequences keep increasing across setups of the same thread so a stream - // opened during a previous setup still accepts the next one's first snapshot. + // Keep sequences increasing across setups so existing subscribers accept the next setup's first snapshot. const lastSequenceByThread = new Map(); const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => @@ -142,8 +138,7 @@ export const make = Effect.gen(function* () { return next; }).pipe( Effect.andThen(publish(threadId, null)), - // A subscriber that outlives retention sees `null` here and accepts any - // sequence after it, so the counter can start over for this thread. + // After retention, subscribers see null and accept any sequence; the counter can reset. Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), ); @@ -300,19 +295,14 @@ export const make = Effect.gen(function* () { const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); - /** - * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only ever - * holds the newest snapshot, so a chatty setup script cannot grow the server - * heap. - */ + // One-slot sliding mailboxes bound slow WebSockets to the newest setup snapshot. const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => Stream.callback( (mailbox) => Effect.gen(function* () { const subscription = yield* PubSub.subscribe(changes); const initial = yield* get(threadId); - // Changes published between subscribing and reading `initial` are - // already folded into it. Drop them so the client never steps back. + // The initial snapshot already includes queued changes; drop them to avoid stale replay. let lastSequence = initial?.sequence ?? -1; Queue.offerUnsafe(mailbox, initial); yield* Stream.fromSubscription(subscription).pipe( diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index 1a65c11d3cfe0..f1d75b532bc7a 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -18,10 +18,7 @@ const STAGE_PREFIXES: ReadonlyArray = [ const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; -/** - * Git redraws each counter with a bare `\r`, so callers hand over each redraw - * as its own line. - */ +/** Pass each bare-CR progress redraw as a separate line. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { const trimmed = line.trim(); const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed)); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts new file mode 100644 index 0000000000000..ad19948c06886 --- /dev/null +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -0,0 +1,135 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { McpProviderSessions } from "@t3tools/provider-core/server/McpProviderSessions"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpServer } from "effect/ai"; + +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { DeviceService } from "../device/DeviceService.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import { PreviewAutomationBroker } from "../mcp/PreviewAutomationBroker.ts"; +import { + CLAUDE_PROVIDER, + makeClaudeQueryOptions, +} from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ProviderAdapterRegistryV2 } from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ThreadSearch } from "../orchestration-v2/ThreadSearch.ts"; +import { PeerForwarding } from "../peer/PeerForwarding.ts"; +import { PeerLinkRequests } from "../peer/PeerLinkRequests.ts"; +import { PeerLinks } from "../peer/PeerLinks.ts"; +import { RemoteDelegation } from "../peer/RemoteDelegation.ts"; +import { PreviewManager } from "../preview/Manager.ts"; +import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; +import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { PullRequestReviewGuideService } from "../pullRequest/PullRequestReviewGuideService.ts"; +import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import { SecretRequests } from "../secrets/SecretRequests.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { SourceControlRepositoryService } from "../sourceControl/SourceControlRepositoryService.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; +import { DocumentText } from "./DocumentText.ts"; +import { ProviderRegistry } from "./ProviderRegistry.ts"; +import budget from "./agentContextBudget.json" with { type: "json" }; + +// Registration only stores tool definitions, so the handlers' services are never called. +const toolHandlerServices = Layer.mergeAll( + Layer.mock(DeviceService)({}), + Layer.mock(DocumentText)({}), + Layer.mock(GitVcsDriver)({}), + Layer.mock(GitWorkflowService)({}), + Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), + Layer.mock(McpProviderSessions)({}), + Layer.mock(OrchestratorV2)({}), + Layer.mock(PeerForwarding)({}), + Layer.mock(PeerLinkRequests)({}), + Layer.mock(PeerLinks)({}), + Layer.mock(PreviewAutomationBroker)({}), + Layer.mock(PreviewBrowser)({}), + Layer.mock(PreviewManager)({}), + Layer.mock(ProjectService)({}), + Layer.mock(ProjectSetupScriptRunner)({}), + Layer.mock(ProviderAdapterRegistryV2)({}), + Layer.mock(ProviderRegistry)({}), + Layer.mock(PullRequestReviewGuideService)({}), + Layer.mock(RemoteDelegation)({}), + Layer.mock(ScheduledTaskService)({}), + Layer.mock(SecretRequests)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-context-budget-" }), + Layer.mock(ServerEnvironment)({}), + Layer.mock(ServerSecretStore)({}), + Layer.mock(ServerSettingsService)({}), + Layer.mock(SourceControlRepositoryService)({}), + Layer.mock(ThreadLaunchService)({}), + Layer.mock(ThreadManagementService)({}), + Layer.mock(ThreadSearch)({}), + Layer.mock(VcsStatusBroadcaster)({}), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const utf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); + +const claudeSystemAppend = () => { + const { systemPrompt } = makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER), + model: "claude-opus-5-5", + }, + nativeThreadId: "context-budget", + resume: false, + cwd: null, + mcpServers: { "t3-code": { type: "http", url: "http://127.0.0.1/mcp" } }, + }); + return typeof systemPrompt === "object" && "append" in systemPrompt ? systemPrompt.append : ""; +}; + +const codexAdditionalContext = () => + Object.values( + buildCodexAdditionalContext( + { model: "gpt-6.1-sol", modelName: "GPT-6.1 Sol", reasoningEffort: "high" }, + { browser: true, device: true }, + ), + ) + .map((entry) => entry.value) + .join(""); + +// CI rejects a PR that raises any value above main's agentContextBudget.json, so growth +// needs the agent-context-increase label. Lower the values when the text shrinks. +it.effect("agent context matches the committed budget", () => + Effect.gen(function* () { + const mcp = yield* McpServer.McpServer; + const measured = { + claudeSystemAppendBytes: utf8Bytes(claudeSystemAppend()), + codexAdditionalContextBytes: utf8Bytes(codexAdditionalContext()), + // The model sees these fields. outputSchema and annotations are protocol metadata. + mcpToolListJsonBytes: utf8Bytes( + JSON.stringify( + mcp.tools.map(({ tool }) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ), + }; + + expect(measured).toEqual(budget); + }).pipe( + Effect.provide( + McpHttpServer.layerTools.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(toolHandlerServices), + ), + ), + ), +); diff --git a/apps/server/src/provider/AgentInstructionFiles.ts b/apps/server/src/provider/AgentInstructionFiles.ts index 0964d129d9248..239e5267fb981 100644 --- a/apps/server/src/provider/AgentInstructionFiles.ts +++ b/apps/server/src/provider/AgentInstructionFiles.ts @@ -47,35 +47,32 @@ const syncManagedInstructionFile = Effect.fn("syncManagedInstructionFile")(funct } const actual = yield* fs.realPath(parent); if (actual !== parent) - return yield* Effect.fail( - new AgentInstructionFileError({ - message: "Agent instruction directories must not be symlinks.", - }), - ); + return yield* new AgentInstructionFileError({ + message: "Agent instruction directories must not be symlinks.", + }); } if (!input.settled) { // This directory holds runtime material, not changes the coding agent should commit. yield* fs .writeFileString(path.join(root, ".agents", "t3", ".gitignore"), "*\n", { flag: "wx" }) .pipe( - Effect.catch((error) => - error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error), + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => Effect.void, ), ); } const file = path.join(root, relativePath); if (yield* fs.exists(file)) { if ((yield* fs.realPath(file)) !== file) - return yield* Effect.fail( - new AgentInstructionFileError({ message: "Agent instruction file must not be a symlink." }), - ); + return yield* new AgentInstructionFileError({ + message: "Agent instruction file must not be a symlink.", + }); const previous = yield* fs.readFileString(file); if (!previous.startsWith(`${marker}\n`)) - return yield* Effect.fail( - new AgentInstructionFileError({ - message: "Refusing to replace an unmanaged agent instruction file.", - }), - ); + return yield* new AgentInstructionFileError({ + message: "Refusing to replace an unmanaged agent instruction file.", + }); if (input.settled || !input.instructions.trim()) { yield* fs.remove(file); // Preserve handoff documents and any user-added files. Only remove empty directories. diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index dfcfb2fecdc8c..af156a61663f4 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -8,6 +8,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; @@ -62,6 +63,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( const authenticated = yield* Deferred.make(); const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); + const authenticateCalled = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: | ((url: string) => Effect.Effect) @@ -92,6 +94,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( start: () => Effect.gen(function* () { events.push("authenticate"); + yield* Deferred.succeed(authenticateCalled, undefined); if (options.interactive !== false && input.onAuthorizationUrl) { for (const url of options.authorizationUrls ?? [authorizationUrl]) { yield* input.onAuthorizationUrl(url); @@ -129,6 +132,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( authenticated, discovered, closed, + authenticateCalled, events, catalog: () => catalog, forwarded: () => forwarded, @@ -254,6 +258,38 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect("tells the user when the agent cannot reach Google", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ interactive: false }); + yield* harness.auth.controller.start(owner); + yield* Deferred.await(harness.authenticateCalled); + + yield* TestClock.adjust("30 seconds"); + const stalled = yield* harness.auth.controller.subscribe(owner).pipe( + Stream.filter((state) => state.message?.includes("Broken IPv6") === true), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(stalled.phase, "starting"); + + yield* TestClock.adjust("270 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "could not reach Google"); + }), + ); + + it.effect("keeps the expired message once the Google link was shown", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + + yield* TestClock.adjust("300 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.equal(failed.message, "Google sign-in expired. Start sign-in again."); + }), + ); + it.effect("accepts direct local or cached completion without a callback RPC", () => Effect.gen(function* () { const harness = yield* makeHarness({ interactive: false }); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c97aa949aab80..67f25681a1a4b 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -31,6 +31,13 @@ import { import type { ProviderAuthController } from "./ProviderAuthService.ts"; const AUTH_TIMEOUT_MS = 300_000; +// A healthy agent shows the Google link or finishes within seconds. Its bundled Python has no +// IPv4 fallback race, so a host with broken IPv6 waits about two minutes per Google request. +const NO_RESPONSE_HINT_MS = 30_000; +const NO_RESPONSE_MESSAGE = + "Antigravity has not reached Google after 30 seconds. Check this machine's network connection. Broken IPv6 is a common cause."; +const UNREACHABLE_MESSAGE = + "Antigravity could not reach Google. Check this machine's network connection, then start sign-in again."; const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; const isSetupError = Schema.is(ProviderSetupError); const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); @@ -264,6 +271,17 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < const runSignIn = Effect.fn("AntigravityAuth.runSignIn")( function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); + yield* Effect.sleep(NO_RESPONSE_HINT_MS).pipe( + Effect.andThen( + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow || flow.state.phase !== "starting") return; + yield* publishFlow(flow, { ...flow.state, message: NO_RESPONSE_MESSAGE }); + }), + ), + ), + Effect.forkScoped, + ); const runtime = yield* options.makeRuntime({ onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), }); @@ -282,13 +300,20 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ); yield* options.onAuthenticated(started, runtime); }, - (effect, flow, stopSessions) => + (effect, flow, _stopSessions) => effect.pipe( Effect.scoped, Effect.timeoutOrElse({ duration: AUTH_TIMEOUT_MS, orElse: () => - Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + Effect.fail( + setupError( + "start", + flow.state.phase === "starting" + ? UNREACHABLE_MESSAGE + : "Google sign-in expired. Start sign-in again.", + ), + ), }), Effect.exit, Effect.flatMap((result) => finishFlow(flow, result)), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e7bcbe7553f67..363eb4b78858a 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -43,10 +43,7 @@ const zipFixtures = { "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", }; -// The installation checks POSIX exec bits off the real filesystem unless the -// platform is win32, so a linux platform mock cannot pass on NTFS. Default to -// the host and let the fixture names follow; the suite is about install -// mechanics, which are the same on every platform. +// Linux platform mocks cannot enforce POSIX exec bits on NTFS; use the host platform. const hostPlatform: NodeJS.Platform = HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const completeArchive = Buffer.from( @@ -300,8 +297,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { it.effect("accepts an encoded Content-Length when the body is compressed", () => Effect.gen(function* () { - // dl.google.com gzips the archive and reports the encoded size. The decoded - // stream is still checked byte for byte and by hash. + // dl.google.com reports the gzip-encoded size; byte and hash checks use the decoded stream. const { installation, validations } = yield* makeHarness({ contentLength: completeArchive.byteLength - 1_000, contentEncoding: "gzip", @@ -573,8 +569,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - // Real posix executables in a real temp dir, resolved by a linux-mocked - // PATH walk; a Windows temp path cannot be split on `:`. + // The mocked Linux PATH walk needs POSIX paths; Windows temp paths contain colons. it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "honors explicit paths and reports invalid overrides without falling back", () => diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 3b7cb6f1e18af..3d590abda0bdb 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -373,8 +373,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma profileDirectory, platform, baseEnv: environment, - // The profile is scoped, so it cleans up the unpack; a shallow - // root keeps it under Windows' path limit. + // A shallow scoped profile cleans up unpacked files within Windows' path limit. tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ @@ -390,9 +389,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ? ChildProcessSpawner.make( Effect.fnUntraced(function* (command) { const child = yield* spawner.spawn(command); - // Stop PyInstaller's tree before ACP closes its pipes. Once - // the launcher exits, taskkill cannot reach its children, - // which keep the executable and unpacked DLLs locked. + // Stop PyInstaller's tree before ACP closes pipes; after launcher exit, taskkill cannot reach children holding DLLs open. yield* Scope.addFinalizer( validationScope, child.kill({ forceKillAfter: "1 second" }).pipe(Effect.orDie), @@ -407,8 +404,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma if ( initialized.agentInfo?.name !== "antigravity-acp" || initialized.agentInfo.version !== expectedVersion || - // Antigravity 1.1.1 can report 2 with the legacy ACP response shape. - // The ACP client chooses the session wire format from that shape. + // Antigravity 1.1.1 may report protocol 2 with legacy ACP; choose wire format from the response shape. (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) || initialized.agentCapabilities?.loadSession !== true || !initialized.agentCapabilities.sessionCapabilities?.resume || @@ -464,7 +460,6 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma { flag: "wx", mode: 0o600 }, ); yield* fs.rename(pointerPath, activePath); - // The pointer commits the install. Later temp cleanup cannot undo it. yield* SubscriptionRef.update( state, (current) => @@ -531,9 +526,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const response = yield* http .execute(HttpClientRequest.get(asset.url)) .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); - // dl.google.com gzips the zip when the client accepts it, so - // `content-length` is the encoded size. The decoded stream is still - // checked against the pinned byte count and hash below. + // dl.google.com may gzip the zip: content-length is encoded, while byte/hash checks use decoded data. const contentLength = response.headers["content-length"]; const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); const identityBody = contentEncoding === undefined || contentEncoding === "identity"; diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 1c1f3d9a56d9c..c6809c3577132 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -160,9 +160,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: initialDraft, authRevision: 0, }); - // Skills the driver discovered on disk per workspace. Session callbacks - // rewrite the workspace entry with native commands and must keep these, or - // the registry drops the suggestions and never re-reads the workspace. + // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), @@ -211,8 +209,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: { ...draft, installed: !missingInstallation, - // Custom launchers have no static version; their lightweight probe - // must not erase the version learned from a real ACP handshake. + // Lightweight custom-launcher probes lack versions; preserve the real handshake's version. version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 80740b0a21d49..c03916564e0f7 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,11 +1,20 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; -import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { + buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeWorkspaceSnapshot, +} from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings); + it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -20,3 +29,15 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); + +it.effect("reports a deleted worktree's scan as finished, not pending", () => + Effect.gen(function* () { + const snapshot = yield* probeClaudeWorkspaceSnapshot( + yield* decodeClaudeSettings({}), + { skills: [], slashCommands: [] } as unknown as ServerProvider, + "/nonexistent/t3-deleted-worktree", + { HOME: "/nonexistent/t3-home" }, + ); + assert.strictEqual(snapshot.slashCommandsPending, false); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index db409077756a3..f67628efb83a3 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -59,23 +59,21 @@ function tryResolveClaudeModelCatalog( }; } -const BUNDLED_CLAUDE_PROVIDER_CATALOG = - resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined; - +/** Resolve the fallback on each read so its new-model badges can expire. */ export function resolveClaudeModelCatalog( catalog: ModelCatalog.ProviderCatalog | undefined, ): ClaudeModelCatalog { return ( tryResolveClaudeModelCatalog(catalog) ?? - tryResolveClaudeModelCatalog(BUNDLED_CLAUDE_PROVIDER_CATALOG) ?? { + tryResolveClaudeModelCatalog( + resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, CLAUDE) ?? undefined, + ) ?? { models: [], } ); } -export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog( - BUNDLED_CLAUDE_PROVIDER_CATALOG, -); +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(undefined); export function scopeClaudeModelCatalog( catalog: ClaudeModelCatalog, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 7a7dcfbe2b1f1..39585126b4193 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -169,11 +169,7 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } -/** - * The capability probe resolves for a logged-out CLI, so a completed probe only proves Claude - * Code started. `tokenSource: "none"` is the CLI reporting it found no token at all, and is the - * one shape that disproves authentication. - */ +/** Probe completion only proves Claude started. Only tokenSource: "none" disproves auth. */ function claudeAuthStatus( capabilities: Pick< ClaudeCapabilitiesProbe, @@ -184,16 +180,14 @@ function claudeAuthStatus( return "authenticated"; } if (capabilities.tokenSource !== "none") return "authenticated"; - // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated - // all the same, so the key and account fields still get a say. + // API keys can authenticate with tokenSource: none; also check key and account fields. const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; return hasApiKey || capabilities.email || capabilities.subscriptionType ? "authenticated" : "unauthenticated"; } -// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the Bedrock -// backend and runs the `awsAuthRefresh` credential hook before returning account info. +// Bedrock initialization includes backend startup and awsAuthRefresh, taking longer than first-party auth. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; // Native CLI startup on Windows can exceed the shared probe deadline. const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000; @@ -216,23 +210,17 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { pathToClaudeCodeExecutable: input.executablePath, abortController: input.abortController, settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], - // The probe keeps filesystem setting sources for slash-command discovery, - // but must not run the user's hooks: it fires every few minutes, so - // SessionStart hooks would run on every health check. + // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly. settings: { disableAllHooks: true }, allowedTools: [], - // Ignore MCP definitions from every filesystem setting source above. The - // SDK combines this empty explicit map with --strict-mcp-config, which - // Claude refuses next to a managed-mcp.json that already excludes them. + // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag. mcpServers: {}, ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { ...input.environment, - // Connected claude.ai MCP servers are discovered outside filesystem - // config; disable them independently for this health check. + // Disable claude.ai MCP separately; it is discovered outside filesystem config. ENABLE_CLAUDEAI_MCP_SERVERS: "false", - // Skipping it also avoids Claude spawning a Windows `tasklist | findstr` process tree on - // every periodic refresh. + // Skipping this avoids a Windows tasklist | findstr process tree on every refresh. FORCE_CODE_TERMINAL: undefined, CLAUDE_CODE_AUTO_CONNECT_IDE: "0", CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", @@ -253,18 +241,10 @@ export type ClaudeCapabilitiesProbe = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly apiKeySource: string | undefined; - /** - * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth - * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) - * the subscription/token fields are absent and auth is external AWS creds. - */ + /** OAuth applies only to firstParty; Bedrock uses external AWS credentials and omits subscription/token fields. */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; - /** - * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). - */ + /** Undefined means get_usage failed; absent windows on success mean no subscription windows, as with API keys. */ readonly usage?: Pick; readonly usageCheckedAt?: string; }; @@ -341,12 +321,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } -/** - * We pass a never-yielding AsyncIterable as the prompt so that no user message is ever written - * to the subprocess stdin. This means the Claude Code subprocess completes its local - * initialization IPC (returning account info and slash commands) but never starts an API - * request to Anthropic. - */ +/** A never-yielding prompt allows local initialization IPC without sending an Anthropic request. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, @@ -363,9 +338,7 @@ const probeClaudeCapabilities = ( const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield + // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), @@ -468,10 +441,14 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); - const probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( - Effect.result, - ); - const capabilities = Result.isSuccess(probeResult) ? probeResult.success : undefined; + const fileSystem = yield* FileSystem.FileSystem; + // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. + const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); + const probeResult = cwdExists + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + : undefined; + const capabilities = + probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; return { ...machineSnapshot, skills, @@ -479,7 +456,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), ]), - slashCommandsPending: !capabilities, + slashCommandsPending: cwdExists && !capabilities, }; }); @@ -659,11 +636,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - // SDK initialization includes cached account claims even after logout, so - // claims are confirmed against the CLI's login store. An install that claims - // no account (an API key, a profile, an older CLI) has nothing stale to - // confirm. Bedrock uses AWS credentials and a custom endpoint its own key, - // not the first-party login store. + // SDK account claims survive logout; confirm them against CLI auth, except for custom endpoints and Bedrock. let authMethod = capabilities.tokenSource; if ( !customApiEndpoint && diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 43b60d21fffa5..d401fde52c2d7 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -1,5 +1,5 @@ import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; -import type { ProviderRuntimeTurnStatus, TurnTokenUsage } from "@t3tools/contracts"; +import type { RuntimeTurnState, TurnTokenUsage } from "@t3tools/contracts"; function finiteNonNegativeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 @@ -23,8 +23,7 @@ function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray, earlierSegmentUsage: ReadonlyArray, @@ -57,7 +56,7 @@ function withEarlierSegmentUsage( export function normalizeClaudeTurnTokenUsage( result: { readonly subtype: SDKResultMessage["subtype"]; readonly usage?: unknown } | undefined, hasSubagents: boolean, - terminalStatus: ProviderRuntimeTurnStatus, + terminalStatus: RuntimeTurnState, earlierSegmentUsage: ReadonlyArray = [], ): TurnTokenUsage { const finalUsage = result?.usage as Record | undefined; diff --git a/apps/server/src/provider/CodexAuthCallback.test.ts b/apps/server/src/provider/CodexAuthCallback.test.ts deleted file mode 100644 index 0dafa54cd1f5b..0000000000000 --- a/apps/server/src/provider/CodexAuthCallback.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Exercise the real local callback receiver without contacting OpenAI. -import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Stream from "effect/Stream"; -import * as NodeHttp from "node:http"; -import { subscribeCodexAuthCallback } from "./CodexAuthCallback.ts"; - -async function input() { - const server = NodeHttp.createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("address"); - await new Promise((resolve) => server.close(() => resolve())); - const authorizationUrl = new URL("https://auth.openai.com/api/accounts/authorize"); - authorizationUrl.search = new URLSearchParams({ - client_id: "dynamic_agent_client", - response_type: "code", - redirect_uri: `http://127.0.0.1:${address.port}/auth/callback`, - state: "a".repeat(43), - code_challenge_method: "S256", - code_challenge: "b".repeat(43), - }).toString(); - return { - authorizationUrl: authorizationUrl.toString(), - returnUrl: "http://localhost:7001/welcome#agents:remote-nuc", - environmentId: EnvironmentId.make("remote-nuc"), - instanceId: ProviderInstanceId.make("work"), - flowId: "remote-flow", - }; -} -function callback(authorizationUrl: string) { - const request = new URL(authorizationUrl); - const url = new URL(request.searchParams.get("redirect_uri")!); - url.search = new URLSearchParams({ - state: request.searchParams.get("state")!, - code: "test-code", - client_id: "oaiapp_test", - }).toString(); - return url; -} - -it.effect("a local primary environment receives sign-in for a remote secondary environment", () => - Effect.gen(function* () { - const request = yield* Effect.promise(input); - const ready = yield* Deferred.make(); - const states: string[] = []; - let receivedCallbackUrl: string | undefined; - const receiver = yield* subscribeCodexAuthCallback(request).pipe( - Stream.runForEach((state) => - Effect.gen(function* () { - states.push(state.phase); - if (state.phase === "finished") receivedCallbackUrl = state.callbackUrl; - if (state.phase === "ready") yield* Deferred.succeed(ready, undefined); - }), - ), - Effect.forkScoped, - ); - yield* Deferred.await(ready); - const expected = callback(request.authorizationUrl); - const foreign = new URL(expected); - foreign.searchParams.set("state", "foreign"); - expect((yield* Effect.promise(() => fetch(foreign))).status).toBe(400); - const response = yield* Effect.promise(() => fetch(expected, { redirect: "manual" })); - expect(response.status).toBe(303); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("location")).toBe(request.returnUrl); - yield* Fiber.join(receiver); - expect(receivedCallbackUrl).toBe(expected.toString()); - expect(states).toEqual(["ready", "finished"]); - }).pipe(Effect.scoped), -); - -it.effect("disconnecting the receiving client releases the exact callback port for retry", () => - Effect.gen(function* () { - const request = yield* Effect.promise(input); - const ready = yield* Deferred.make(); - const receiver = yield* subscribeCodexAuthCallback(request).pipe( - Stream.runForEach(() => Deferred.succeed(ready, undefined)), - Effect.forkScoped, - ); - yield* Deferred.await(ready); - yield* Fiber.interrupt(receiver); - yield* subscribeCodexAuthCallback(request).pipe( - Stream.runForEach((state) => - state.phase === "ready" - ? Effect.promise(() => fetch(callback(request.authorizationUrl), { redirect: "manual" })) - : Effect.void, - ), - ); - }).pipe(Effect.scoped), -); - -it.effect("the local server refuses nonlocal return destinations", () => - Effect.gen(function* () { - const request = yield* Effect.promise(input); - const result = yield* subscribeCodexAuthCallback({ - ...request, - returnUrl: "https://app.t3.codes/welcome", - }).pipe(Stream.runDrain, Effect.result); - expect(result._tag).toBe("Failure"); - }), -); diff --git a/apps/server/src/provider/CodexAuthCallback.ts b/apps/server/src/provider/CodexAuthCallback.ts deleted file mode 100644 index b2facc60bdb2b..0000000000000 --- a/apps/server/src/provider/CodexAuthCallback.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ProviderSetupError, type CodexAuthCallbackInput } from "@t3tools/contracts"; -import { receiveCodexAuthCallback } from "@t3tools/shared/codexAuthCallback"; -import { codexAuthorizationRequest } from "@t3tools/shared/codexAuthHandoff"; -import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; -import { isLoopbackHost } from "@t3tools/shared/preview"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Stream from "effect/Stream"; - -export function subscribeCodexAuthCallback(input: CodexAuthCallbackInput) { - const failure = (error: unknown) => - new ProviderSetupError({ - instanceId: input.instanceId, - operation: "callback", - detail: - error instanceof Error ? error.message : "Could not receive sign-in on this computer.", - }); - return Stream.unwrap( - Effect.gen(function* () { - const destination = yield* Effect.try({ - try: () => { - codexAuthorizationRequest(input.authorizationUrl); - const destination = providerAuthReturnUrl(input.returnUrl); - if (!destination || !isLoopbackHost(new URL(destination).hostname)) - throw new Error("The local sign-in receiver needs a local T3 Code return address."); - return destination; - }, - catch: failure, - }); - const ready = yield* Deferred.make(); - const runSync = Effect.runSyncWith(yield* Effect.context()); - const callback = yield* Effect.tryPromise({ - try: (signal) => - receiveCodexAuthCallback( - input.authorizationUrl, - async () => { - runSync(Deferred.succeed(ready, undefined)); - return true; - }, - () => destination, - signal, - ), - catch: failure, - }).pipe( - Effect.tapError((error) => Deferred.fail(ready, error)), - Effect.forkScoped, - ); - return Stream.fromEffect(Deferred.await(ready)).pipe( - Stream.map(() => ({ phase: "ready" as const })), - Stream.concat( - Stream.fromEffect(Fiber.join(callback)).pipe( - Stream.map((callbackUrl) => ({ phase: "finished" as const, callbackUrl })), - ), - ), - ); - }), - ); -} diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 20cc69cac391e..3e51b66a0e5ee 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -306,8 +306,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* for (const session of "sessions" in saved ? saved.sessions : [saved]) { if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; connections.add(session.clientId); - // Subjects are client-scoped. Use verified email only for counting locally, - // never export it or merge the underlying profiles. + // Client-scoped subjects need verified email for local counting only; never export email or merge profiles. const email = session.email?.trim().toLowerCase(); if (email) accounts.add(email); else unidentifiedConnectedConnectionCount++; @@ -460,8 +459,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const savedRegistration = changingAccount ? undefined : registrations.profiles.find((profile) => profile.clientId === selectedClientId); - // Older development registrations used localhost, which cannot be changed on reauth. - // Register a 127.0.0.1 connection instead, preserving the verified account. + // Legacy localhost registrations cannot change on reauth; register 127.0.0.1 while preserving the verified account. const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true; const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId; const selectedTokens = (yield* readSessions).sessions.find( @@ -640,8 +638,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const expectedSubject = savedRegistration?.subject ?? (previous?.clientId === registeredClientId ? previous?.subject : undefined); - // Subjects are checked within the same registration. Legacy callback migration - // registers a new client, whose subject cannot be compared with the old client. + // Compare subjects only within a registration; legacy callback migration creates a new client. if (registeredClientId && expectedSubject && identity.subject !== expectedSubject) return yield* failure( "verify", @@ -739,78 +736,69 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ) .pipe(Effect.map(Hex.encode), Effect.orDie); - const access = lock.withPermit( - withSessionLock( - Effect.uninterruptible( - Effect.gen(function* () { - const saved = yield* read; - if (Option.isNone(saved)) - return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); - let record = saved.value; - if ( - options.expectedProfileKey !== undefined && - (yield* profileKey(record)) !== options.expectedProfileKey - ) - return yield* failure("access", "The ChatGPT connection changed. Start a new session."); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", - ); - const now = yield* Clock.currentTimeMillis; - if (record.expiresAt - now > 60_000) return record; - if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { - if (record.expiresAt > now) return record; - return yield* failure( - "refresh", - "ChatGPT cannot renew this connection yet. Try again shortly.", - ); - } - if (!record.refreshToken) { - yield* remove; - return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); - } - const tokens = yield* exchange( - new URLSearchParams({ - grant_type: "refresh_token", - client_id: record.clientId, - refresh_token: record.refreshToken, - resource, - }), - ).pipe( - Effect.interruptible, - Effect.timeout("20 seconds"), - Effect.mapError((error) => - error._tag === "ProviderSetupError" && error.operation === "client" - ? error - : failure( - "refresh", - "Could not renew the ChatGPT connection. Retry, or sign in again.", - ), - ), - ); - if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { - return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); - } - record = { - ...record, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, - earliestRefreshAt: earliest(tokens.earliest_refresh_at), - scopes: tokens.scope.split(/\s+/).filter(Boolean), - }; - yield* save(record); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "ChatGPT token sharing is no longer enabled. Sign in again.", - ); - return record; - }), + const access = Effect.gen(function* () { + const saved = yield* read; + if (Option.isNone(saved)) + return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); + let record = saved.value; + if ( + options.expectedProfileKey !== undefined && + (yield* profileKey(record)) !== options.expectedProfileKey + ) + return yield* failure("access", "The ChatGPT connection changed. Start a new session."); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", + ); + const now = yield* Clock.currentTimeMillis; + if (record.expiresAt - now > 60_000) return record; + if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { + if (record.expiresAt > now) return record; + return yield* failure( + "refresh", + "ChatGPT cannot renew this connection yet. Try again shortly.", + ); + } + if (!record.refreshToken) { + yield* remove; + return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); + } + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: record.clientId, + refresh_token: record.refreshToken, + resource, + }), + ).pipe( + Effect.interruptible, + Effect.timeout("20 seconds"), + Effect.mapError((error) => + error._tag === "ProviderSetupError" && error.operation === "client" + ? error + : failure("refresh", "Could not renew the ChatGPT connection. Retry, or sign in again."), ), - ), - ); + ); + if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { + return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); + } + record = { + ...record, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes: tokens.scope.split(/\s+/).filter(Boolean), + }; + yield* save(record); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "ChatGPT token sharing is no longer enabled. Sign in again.", + ); + return record; + }).pipe(Effect.uninterruptible, withSessionLock, lock.withPermit); const logout = Effect.gen(function* () { const credentials = Option.getOrUndefined(yield* read); let confirmed = !credentials?.refreshToken; diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts index 0b73ae0cf1ab7..9d00a1b966e50 100644 --- a/apps/server/src/provider/CodexChatGptHandoff.ts +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -18,17 +18,25 @@ export function subscribeChatGptHandoff( const bytes = new Map(); yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear())); const store = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), - set: (key, value) => - Effect.sync(() => { + get: Effect.fn("ServerSecretStore.get")(function (key) { + return Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))); + }), + set: Effect.fn("ServerSecretStore.set")(function (key, value) { + return Effect.sync(() => { bytes.set(key, value); - }), - remove: (key) => - Effect.sync(() => { + }); + }), + remove: Effect.fn("ServerSecretStore.remove")(function (key) { + return Effect.sync(() => { bytes.delete(key); - }), - create: () => Effect.die("Handoff does not create persistent secrets."), - getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."), + }); + }), + create: Effect.fn("ServerSecretStore.create")(function () { + return Effect.die("Handoff does not create persistent secrets."); + }), + getOrCreateRandom: Effect.fn("ServerSecretStore.getOrCreateRandom")(function () { + return Effect.die("Handoff uses the destination environment identity."); + }), }); const auth = yield* makeCodexChatGptAuth({ ...endpoints, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index c56e3321d1daf..6c01febe26b48 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -9,11 +9,15 @@ import { CODEX_APP_CONTEXT_MAX_TOTAL_BYTES, } from "./CodexDeveloperInstructions.ts"; +function runtimeInstructions(runtime: Parameters[0]) { + return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; +} + describe("buildCodexAdditionalContext", () => { it.each([undefined, { browser: true, device: true }])( "keeps every entry within Codex's per-entry cap with tools %j", (availability) => { - const context = buildCodexAdditionalContext(availability); + const context = buildCodexAdditionalContext({ model: "gpt-6.1-sol" }, availability); for (const [key, entry] of Object.entries(context)) { // Codex counts ceil(UTF-8 bytes / 4) tokens, excluding keys and wrappers. @@ -28,9 +32,35 @@ describe("buildCodexAdditionalContext", () => { ); }); +describe("Codex runtime context", () => { + it("describes Markdown media support in the runtime context in both modes", () => { + { + const instructions = runtimeInstructions({ + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + NodeAssert.match( + instructions, + /.*embed images, video, and audio.*Markdown image syntax with absolute file paths.*Link any other file.*absolute path.*files viewer.*<\/runtime_info>/, + ); + } + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = runtimeInstructions({ + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex/); + NodeAssert.doesNotMatch(instructions, /reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + describe("Codex orchestration context", () => { it("routes same-child follow-ups to t3_thread_send and independent reviews to delegate_task", () => { - const orchestration = Object.entries(buildCodexAdditionalContext()) + const orchestration = Object.entries(buildCodexAdditionalContext({ model: "gpt-6.1-sol" })) .filter(([key]) => key.startsWith("t3_code_orchestration_")) .map(([, entry]) => entry.value) .join("\n"); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index c6f5af66e35fc..aeca6a30cabae 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,4 +1,3 @@ -import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; @@ -7,11 +6,7 @@ import { T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; -/** - * Codex cuts the middle out of any `additionalContext` value over 1,000 tokens, - * which it counts as UTF-8 bytes divided by four. It does this silently, so - * every value built here has to fit. - */ +/** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; /** @@ -71,7 +66,7 @@ export function buildCodexAppContext( const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices -The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, using the exact launcher path returned by \`device_open\`. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Prefer these tools and \`agent-device\` for opening and driving devices. Platform tools such as \`xcrun simctl\` and \`adb\` remain available for anything they do not cover, such as builds, logs, or port forwarding. If \`device_list\` reports a platform as unavailable, say so.`; +For iOS Simulators and Android Emulators here, call \`device_list\`, then \`device_open\`. Drive the device with the exact \`agent-device\` launcher and flags it returns, on every command. Prefer \`agent-device snapshot -i\` refs over coordinates. Use \`device_screenshot\` to see the screen. Use \`xcrun simctl\` or \`adb\` only for builds, logs, and ports. If a platform is unavailable, say so.`; export interface T3CodeToolAvailability { readonly browser: boolean; @@ -83,12 +78,7 @@ const normalizeAvailability = ( ): T3CodeToolAvailability => typeof availability === "boolean" ? { browser: availability, device: false } : availability; -/** - * Describing `preview_*` or `device_*` tools that aren't in the turn's tool list would be worse - * than saying nothing: the instructions actively steer the model away from Playwright, - * agent-browser, and raw simctl/adb, so leaving them in would talk it out of the only automation - * it still has. - */ +/** Instructions for unavailable tools would steer the model away from its remaining automation. */ const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { const tools = normalizeAvailability(availability); return [ @@ -99,81 +89,19 @@ const toolInstructions = (availability: boolean | T3CodeToolAvailability): strin .join("\n\n"); }; -const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) - -You are in **Plan Mode** until a developer message explicitly ends it. User intent, tone, or imperative language does not change the mode: treat a request to execute as a request to **plan the execution**. Chat your way to a plan that is **decision complete**: detailed enough in intent and implementation that another engineer or agent could implement it right away without making any decisions. - -\`update_plan\` is a separate checklist/progress tool. It does not enter or exit Plan Mode, and it returns an error if used in Plan Mode. - -## Non-mutating work only - -Do non-mutating work that improves the plan: read and search files, configs, schemas, types, manifests, and docs; run static analysis, dry runs, and tests, builds, or checks that write only to caches or build artifacts (for example \`target/\` or \`.cache/\`), never to repo-tracked files. - -Do not edit or write files, run formatters or linters that rewrite files, apply patches, migrations, or codegen that change repo-tracked files, or run side-effectful commands that carry out the plan. When in doubt: if it would reasonably be described as "doing the work" rather than "planning the work," do not do it. - -## Phases - -1. **Ground in the environment.** Resolve discoverable facts by exploring, not asking; silent exploration is encouraged. Do at least one targeted exploration pass before asking anything, unless no local environment is available. You may ask first only about obvious ambiguities or contradictions in the prompt itself, and only when exploring could not resolve them. Never ask what the repo or system can answer (for example, "where is this struct?"). If several plausible candidates remain, present them (paths, service names) and recommend one. -2. **Intent.** Keep asking until you can state the goal and success criteria, audience, what is in and out of scope, constraints, current state, and key preferences or tradeoffs. If a high-impact ambiguity remains, ask instead of guessing. -3. **Implementation.** Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs, schemas, I/O), data flow, edge cases and failure modes, testing and acceptance criteria, rollout and monitoring, and migrations or compatibility constraints. - -## Asking questions - -Strongly prefer the \`request_user_input\` tool, with only meaningful options and no filler. Ask directly without it only when an unavoidable, important question cannot be expressed as reasonable multiple-choice options. Ask as many questions as needed, but each must materially change the plan, confirm or lock an assumption, or choose between meaningful tradeoffs, and must not be answerable by non-mutating exploration. Ask about preferences and tradeoffs early, with 2-4 mutually exclusive options and a recommended default; if unanswered, proceed with the recommendation and record it as an assumption in the final plan. - -## Final plan - -Present the plan only when it is decision complete, wrapped in a \`\` block so the client can render it: - -1) The opening tag must be on its own line. -2) Start the plan content on the next line (no text on the same line as the tag). -3) The closing tag must be on its own line. -4) Use Markdown inside the block. -5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. - - -plan content - - -The plan is plan-only, human and agent digestible, and concise by default. Include a clear title, a brief summary, important changes to public APIs, interfaces, or types, test cases and scenarios, and explicit assumptions and defaults. Prefer 3-5 short sections (usually Summary, Key Changes, Test Plan, Assumptions) with short bullets grouped by subsystem or behavior, not file-by-file; name files only to disambiguate, usually no more than 3. Leave out repeated invariants, unaffected behavior, and schema, validation, or wire-shape policy the request does not establish; prefer the minimum interface and behavior changes. Expand only if the user asks. - -Do not ask "should I proceed?": the user can switch out of Plan mode to implement, or stay to refine. Produce at most one \`\` block per turn. A revised plan must be a complete replacement. If the user rejects the plan without enough information for a replacement, address the concern and keep planning without a block. If a follow-up neither requires changes nor calls the plan into question (for example, a clarifying question), answer it before the block, then reproduce the prior \`\` unchanged. -`; - -const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default - -You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. - -Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. - -## request_user_input availability - -Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. - -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -`; - -export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { - return interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; +export interface CodexRuntimeInfo { + readonly model: string; + readonly modelName?: string | undefined; + readonly reasoningEffort?: string | undefined; } -/** - * This must stay out of the collaboration mode: when the model catalog ships - * its own text for a mode, as newer models do, Codex uses that text and drops - * the client's `developer_instructions` entirely. - * - * Model and effort stay out of the text because they already go to Codex as - * turn settings; naming them here would change developer context on every - * effort toggle. - */ +/** Keep this outside collaboration mode; model-catalog mode text overrides client developer_instructions. */ export function buildCodexAdditionalContext( + runtime: CodexRuntimeInfo, toolsAvailable: boolean | T3CodeToolAvailability = true, ): Record { const tools = toolInstructions(toolsAvailable); - // Separate keys keep each value under Codex's per-entry cap. Codex emits - // entries in key order, so the numbering preserves the authored order. + // Split values to fit per-entry caps; number keys because Codex emits them in key order. return { ...Object.fromEntries( T3_CODE_ORCHESTRATION_SECTIONS.map((section, index) => [ @@ -183,7 +111,12 @@ export function buildCodexAdditionalContext( ), t3_code_runtime: { kind: "application", - value: buildRuntimeInstructions({ harness: "Codex" }), + // Effort changes already travel as turn settings; repeating them here busts the prompt cache. + value: buildRuntimeInstructions({ + harness: "Codex", + model: runtime.model, + modelName: runtime.modelName, + }), }, ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), }; diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index e481f95e3c578..758eb406f1b54 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -202,7 +202,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function "resolve", "The managed Codex installation record is invalid. Reinstall Codex.", ); - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))( yield* fs.readFileString(file), ); }); @@ -597,8 +597,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function }), { flag: "wx", mode: 0o600 }, ); - // On Windows, Defender and kernel process rundown can briefly hold the - // verified codex.exe handle open right after `--version` exits. + // Windows Defender or process rundown may retain codex.exe handles briefly after --version exits. const renameRuntime = fs.rename(runtime, destination); yield* platform === "win32" ? renameRuntime.pipe(Effect.retry({ times: 10, schedule: Schedule.spaced("100 millis") })) @@ -741,9 +740,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ); yield* Effect.gen(function* () { - // Two servers can share a home, and writes below a staging directory do not - // refresh its mtime, so only directories far older than any real install - // (download is capped at 45 minutes) are treated as abandoned. + // Shared-home installs do not refresh parent mtimes; remove only staging directories far older than the 45-minute download cap. let canRemove = false; if (yield* fs.exists(managedDirectory)) { const entries = yield* fs @@ -762,8 +759,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function if (now - modifiedAt < STAGING_STALE_AFTER_MS) continue; yield* fs.remove(target, { recursive: true, force: true }).pipe(Effect.ignore); } - // The empty root itself stays: deleting it could race another server - // creating a staging directory inside. An unreadable root stays removable. + // Keep the empty root to avoid racing new staging directories; unreadable roots remain removable. const remaining = yield* fs.readDirectory(managedDirectory).pipe(Effect.option); canRemove = Option.isNone(remaining) || remaining.value.length > 0; } diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts index a1f253c922ed4..0436011620a1f 100644 --- a/apps/server/src/provider/CodexManagedErrors.test.ts +++ b/apps/server/src/provider/CodexManagedErrors.test.ts @@ -11,7 +11,6 @@ it("maps streamed failures to safe actionable messages and reconnect decisions", { message: "ChatGPT could not validate this connection. Check the selected account and sharing permissions.", - revoke: false, code: "subscription_sharing_v2_invalid_user", }, ); @@ -23,7 +22,6 @@ it("maps streamed failures to safe actionable messages and reconnect decisions", classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.code, "subscription_sharing_usage_limit_exceeded", ); - assert.isFalse(classifyCodexManagedError("subscription_sharing_usage_unavailable")!.revoke); assert.include( classifyCodexManagedError("subscription_sharing_unsupported_capability")!.message, "feature", @@ -44,7 +42,6 @@ it("distinguishes unsupported tools from input items without exposing the raw re assert.isDefined(failure); assert.include(failure!.message, expected!); assert.notInclude(failure!.message, "dummy-sensitive"); - assert.isFalse(failure!.revoke); } } }); @@ -59,6 +56,5 @@ it("preserves credentials for the current subscriber and permission error codes" "chatpass_v2_invalid_authorization_context", ]) { assert.strictEqual(classifyCodexManagedError({ error: { code } })?.code, code); - assert.isFalse(classifyCodexManagedError({ error: { code } })!.revoke); } }); diff --git a/apps/server/src/provider/CodexManagedErrors.ts b/apps/server/src/provider/CodexManagedErrors.ts index f2ee114c74fd1..0dd42c37f6397 100644 --- a/apps/server/src/provider/CodexManagedErrors.ts +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -2,44 +2,35 @@ const legacyFailures = { subscription_sharing_v2_user_not_eligible: { message: "ChatGPT sharing is unavailable for this account or workspace. Use another provider or check its sharing policy.", - revoke: false, }, subscription_sharing_usage_limit_exceeded: { message: "Your ChatGPT usage limit was reached. Check ChatGPT Usage settings for your available allowance.", - revoke: false, }, subscription_sharing_workplace_individual_usage_limit_exceeded: { message: "You reached your workspace's limit for using ChatGPT in other apps. Ask your workspace admin to increase it.", - revoke: false, }, subscription_sharing_usage_unavailable: { message: "ChatGPT usage is temporarily unavailable. Try again shortly.", - revoke: false, }, subscription_sharing_unsupported_capability: { message: "Codex used a feature that ChatGPT sharing does not support. Use another provider for this request.", - revoke: false, }, subscription_sharing_v2_client_not_enabled: { message: "This app is not enabled for this ChatGPT connection. Use your existing CLI or another provider.", - revoke: false, }, subscription_sharing_v2_route_not_supported: { message: "ChatGPT does not support this request route.", - revoke: false, }, subscription_sharing_v2_invalid_user: { message: "ChatGPT could not validate this connection. Check the selected account and sharing permissions.", - revoke: false, }, subscription_sharing_v2_user_unavailable: { message: "ChatGPT is temporarily unavailable. Try again shortly.", - revoke: false, }, } as const; const failures = { @@ -52,12 +43,10 @@ const failures = { chatpass_v2_scope_not_authorized: { message: "This ChatGPT grant does not authorize the request. Check the connection's sharing permissions.", - revoke: false, }, chatpass_v2_invalid_authorization_context: { message: "ChatGPT could not authorize this connection. Check the client and sharing permissions.", - revoke: false, }, }; export function classifyCodexManagedError(value: unknown) { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index c1aabfe02342f..b317dd03c234c 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -71,10 +72,7 @@ const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; const CODEX_CLIENT_NAME = "T3 Code"; -// Codex's probe does a full handshake plus account, models, and skills requests, which can -// legitimately run past the shared 10s AUTH_PROBE_TIMEOUT_MS on a loaded or slow machine -// (most often reported on Windows). Give Codex its own longer budget instead of raising the -// timeout for every provider. +// Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; @@ -440,12 +438,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio ); }); -/** - * App-server starts its catalog refresh before `initialize` names the client, - * so that first fetch goes out as the plain CLI, misses tiers like Sol - * Ultrafast, and overwrites the shared `models_cache.json`. Naming the - * originator at spawn closes that race. - */ +/** Set originator at spawn; the pre-initialize refresh otherwise overwrites shared cache with plain-CLI models. */ export const codexAppServerEnvironment = ( environment: NodeJS.ProcessEnv | undefined, ): NodeJS.ProcessEnv => ({ @@ -473,9 +466,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) : undefined; @@ -566,7 +557,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. input.skipNativeUsage - ? Effect.succeed(undefined) + ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + Effect.succeed(undefined) : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), deadlineMs, @@ -810,11 +802,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models: applyConfiguredCodexServiceTier( - applyPreferredCodexDefaultModel( - appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), - ), - snapshot.serviceTier, + models: pipe( + appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), + applyPreferredCodexDefaultModel, + (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts index 6f4959627eff9..7b0f20a123ffe 100644 --- a/apps/server/src/provider/CodexTurnTokenUsage.ts +++ b/apps/server/src/provider/CodexTurnTokenUsage.ts @@ -66,10 +66,7 @@ export function getCodexTurnAccumulator( return created; } -/** - * Codex reports a running `total` for the thread and `last`, the usage of the - * newest model response. - */ +/** total is thread-wide; last covers the newest model response. */ function codexTurnTokenUsageDelta( previous: CodexCumulativeTokenUsage | undefined, current: CodexCumulativeTokenUsage, @@ -104,8 +101,7 @@ export function accumulateCodexTurnTokenUsage( ): void { const current = codexTokenUsageBreakdown(usage.total); if (state.activeTurnId !== turnId) { - // The total is thread-wide, so every update moves the baseline. A late - // update for a finished turn is not counted toward the live turn. + // Every thread-wide total updates the baseline; late finished-turn updates do not count toward the live turn. state.baseline = current; return; } diff --git a/apps/server/src/provider/CommandCodeAdapter.ts b/apps/server/src/provider/CommandCodeAdapter.ts index 5f886190808c2..50b66571b015f 100644 --- a/apps/server/src/provider/CommandCodeAdapter.ts +++ b/apps/server/src/provider/CommandCodeAdapter.ts @@ -507,7 +507,7 @@ export const makeCommandCodeAdapter = Effect.fn("makeCommandCodeAdapter")(functi active = undefined; }), ), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); }); const runtime: ProviderAdapterV2SessionRuntime = { diff --git a/apps/server/src/provider/DocumentText.ts b/apps/server/src/provider/DocumentText.ts new file mode 100644 index 0000000000000..83aa014257ae6 --- /dev/null +++ b/apps/server/src/provider/DocumentText.ts @@ -0,0 +1,120 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the isolated parser process is an external runtime boundary. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +const extensions = new Set(["docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub", "ipynb"]); + +class DocumentTextError extends Schema.TaggedError()("DocumentTextError", { + operation: Schema.Literal("extract"), + path: Schema.String, + extension: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} ${this.extension} document text.`; + } +} + +export const resolveExtractedTextPath = Effect.fn("DocumentText.resolveExtractedTextPath")( + function* (path: string) { + const extension = path.split(".").at(-1)?.toLowerCase(); + if (!extension || !extensions.has(extension)) return; + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path + ".txt").pipe( + Effect.map((exists) => (exists ? path + ".txt" : undefined)), + Effect.orElseSucceed(() => undefined), + ); + }, +); + +export class DocumentText extends Context.Service< + DocumentText, + { + readonly ensure: (path: string) => Effect.Effect; + } +>()("t3/provider/DocumentText") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const executable = yield* HostProcess.ExecutablePath; + const isExecutable = yield* HostProcess.IsExecutable; + const environment = yield* HostProcess.Environment; + const clock = yield* Clock.Clock; + const lock = yield* Semaphore.make(1); + const failed = new Map(); + // In a bundle import.meta.url is bin.mjs; development and tests use this source module. + const entry = NodeURL.fileURLToPath( + new URL(import.meta.url.endsWith(".ts") ? "../bin.ts" : "./bin.mjs", import.meta.url), + ); + const extract = Effect.fn("DocumentText.extract")(function* (path: string, extension: string) { + const sidecar = `${path}.txt`; + if (yield* fs.exists(sidecar)) return sidecar; + if ((failed.get(path) ?? 0) > clock.currentTimeMillisUnsafe()) return; + const info = yield* fs.stat(path); + if (Number(info.size) > 16 * 1024 * 1024) return; + const content = yield* Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + executable, + [...(isExecutable ? [] : [entry]), "__document-text", path, `.${extension}`], + { + signal, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024 + 1024, + encoding: "utf8", + windowsHide: true, + env: { + ...environment, + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: `${environment.NODE_OPTIONS ?? ""} --max-old-space-size=192`, + }, + }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + }), + catch: (cause) => new DocumentTextError({ operation: "extract", path, extension, cause }), + }).pipe( + Effect.tapError(({ cause }) => { + if (failed.size >= 1024) failed.delete(failed.keys().next().value!); + failed.set( + path, + cause instanceof Error && "code" in cause && cause.code === 2 + ? Infinity + : clock.currentTimeMillisUnsafe() + 1_000, + ); + return Effect.logWarning("Document text extraction failed.", { extension }); + }), + ); + const pending = `${sidecar}.part`; + yield* fs + .writeFileString(pending, content) + .pipe( + Effect.andThen(fs.rename(pending, sidecar)), + Effect.ensuring(fs.remove(pending, { force: true }).pipe(Effect.ignore)), + Effect.uninterruptible, + ); + return sidecar; + }, lock.withPermits(1)); + const ensure = Effect.fn("DocumentText.ensure")( + function* (path: string) { + const extension = path.split(".").at(-1)?.toLowerCase(); + if (!extension || !extensions.has(extension)) return; + const sidecar = `${path}.txt`; + if (yield* fs.exists(sidecar)) return sidecar; + return yield* extract(path, extension); + }, + Effect.orElseSucceed(() => undefined), + ); + return DocumentText.of({ ensure }); +}); + +export const layer = Layer.effect(DocumentText, make); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 0f42271c62d22..fd5725c6cd798 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,3 @@ -import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -215,11 +214,9 @@ export const AntigravityDriver: ProviderDriver< : cause, ), ); - // Each process unpacks into its own directory that dies with the - // runtime scope, after the child is killed. A shared directory would - // let one session's teardown delete files a sibling still reads. - // Removal is best effort: a handle can outlive the kill on Windows, - // and the sweep on the next driver start reclaims what is left. + // Use one directory per process; shared cleanup could delete files another session still reads. + // Remove after the child dies, best-effort on Windows where handles may outlive the kill; + // the next driver start sweeps leftovers. const runtimeTempDirectory = yield* Effect.acquireRelease( fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( Effect.mapError( @@ -249,7 +246,7 @@ export const AntigravityDriver: ProviderDriver< installation: executable, profile, cwd: input.cwd, - baseEnv: withAgentDeviceEnvironment(processEnvironment, input), + baseEnv: processEnvironment, auth, runtimeTempDirectory, }), diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 7b25e24d90817..19e9d8d293f48 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -7,12 +7,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; -/** - * The home directory the agent expands `~` against, matching Python's - * `os.path.expanduser` in the launch environment T3 hands the process: - * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` - * elsewhere. - */ +/** Match Python os.path.expanduser: USERPROFILE, then HOMEDRIVE+HOMEPATH on Windows; HOME elsewhere. */ export function resolveAntigravityUserHome( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, @@ -28,12 +23,7 @@ export function resolveAntigravityUserHome( return environment.HOME || home; } -/** - * The agent resolves both under `GEMINI_HOME`, which T3 points at a private - * profile, so the profile links these back to the user's `~/.gemini`. - * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a - * project directory. - */ +/** Link private GEMINI_HOME skills to ~/.gemini; ~/.agents/skills is ignored because .agents/skills is project-only. */ export function antigravityUserSkillDirectories( path: Path.Path, geminiHome: string, @@ -147,9 +137,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( return bytes.toString("utf8"); }); -/** - * Read failures remain typed so workspace snapshots do not cache partial results. - */ +/** Keep read errors typed so workspace snapshots cannot cache partial results. */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; readonly userHome: string; diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index e2bf2b99eefa1..5e4cbb4db6efe 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -7,20 +7,13 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -/** - * Windows launcher-script extensions that Node cannot spawn without a shell - * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore - * cannot use as `pathToClaudeCodeExecutable`. - */ +/** Node 20.12+ cannot spawn Windows launcher scripts without a shell; the SDK cannot use these as executables. */ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); export const isWindowsClaudeLauncherShimPath = (filePath: string): boolean => WINDOWS_SHIM_EXTENSIONS.has(NodePath.win32.extname(filePath).toLowerCase()); -/** - * Newer package versions ship a native `bin/claude.exe`; older versions only ship `cli.js`, - * which the SDK runs with a JavaScript runtime. - */ +/** New packages provide bin/claude.exe; older packages need a JavaScript runtime for cli.js. */ const NPM_PACKAGE_ENTRY_CANDIDATES = [ ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], @@ -43,12 +36,7 @@ export const ClaudeExecutableFileCheck = Context.Reference( }, ); -/** - * The SDK spawns the given path without a shell and without Windows PATH / PATHEXT resolution, - * so a bare command name like `claude` fails with "native binary not found" and an npm - * `claude.cmd` shim fails with `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, - * which can fall back to `shell: true`; the SDK offers no such escape hatch. - */ +/** On Windows the SDK skips shell and PATH/PATHEXT lookup: bare names and .cmd shims fail. */ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { const platform = yield* HostProcess.Platform; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 0c7908b0264ca..bd81aa10bce7a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -37,21 +37,12 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - // Overriding HOME also relocates the macOS login keychain lookup - // ($HOME/Library/Keychains), so the spawned CLI can't find its stored - // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points - // Claude Code at its config dir directly while leaving HOME (and the - // keychain) intact. + // Changing HOME hides macOS Keychain credentials; use CLAUDE_CONFIG_DIR to keep login access. CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); -/** - * While it exists Claude refuses `--strict-mcp-config` and exits before - * initializing, but it already ignores user, project and `--mcp-config` servers - * on its own, so callers that pass the flag for isolation can drop it. Paths - * match the ones Claude Code checks. - */ +/** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; const platform = yield* HostProcess.Platform; @@ -85,10 +76,7 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC }, ); -/** - * Describe the spawned CLI's environment separately from the login command so - * paths remain literal on every shell, including relative inherited values. - */ +/** Separate spawn env from login commands to preserve literal paths, including relative inherited values. */ export const claudeSignedOutMessage = (input: { readonly configDir: string | undefined; readonly cwd: string; diff --git a/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts index 6504deb0ee22c..0bb3c36e9f192 100644 --- a/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts +++ b/apps/server/src/provider/Drivers/ClaudeSessionTranscript.ts @@ -11,20 +11,9 @@ const pathKind = (target: string): Promise<"directory" | "file" | "missing" | "u cause.code === "ENOENT" || cause.code === "ENOTDIR" ? "missing" : "unknown", ); -/** - * Whether Claude's session storage under `configDir` holds a transcript for - * `sessionId`, which is what `--resume ` needs to succeed. - * - * The CLI files each transcript as `projects//.jsonl`. The - * cwd encoding differs by platform and CLI version (drive letters, Windows - * separators, long-path hashing), so every project directory is searched - * rather than re-deriving it. Session ids are UUIDs, so a hit in any project - * is this session. - * - * Returns `undefined` when the answer is unknown (no config dir, or storage - * that cannot be read), so callers keep their previous assumption instead of - * discarding a session they simply cannot see. - */ +/** Resume needs projects//.jsonl; encoding varies by platform/version, + * so search all project directories for the UUID instead of re-deriving the cwd encoding. + * Return undefined for missing config or unreadable storage so callers retain their assumption. */ export const claudeSessionTranscriptExists = (input: { readonly configDir: string; readonly sessionId: string; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 975eb841b67a3..64dd52ada3b1d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -20,7 +20,6 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({ leadingText: "ok, now /implement all the tickets", commandText: "/implement all the tickets", - skillName: "implement", }); }); @@ -28,7 +27,6 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({ leadingText: undefined, commandText: "/review\nfocus on auth", - skillName: "review", }); }); @@ -36,7 +34,6 @@ describe("planClaudeSkillDispatch", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", - skillName: "implement", }); }); @@ -50,12 +47,10 @@ describe("planClaudeSkillDispatch", () => { ).toEqual({ leadingText: "/review the diff, then /implement the fixes", commandText: "/implement the fixes", - skillName: "implement", }); expect(planClaudeSkillDispatch(`${symbol}2spec for this`, SKILLS)).toEqual({ leadingText: undefined, commandText: "/2spec for this", - skillName: "2spec", }); expect(planClaudeSkillDispatch(`5${symbol}review ${symbol}unknown`, SKILLS)).toBeUndefined(); } @@ -86,7 +81,6 @@ describe("planClaudeSkillDispatch", () => { ).toEqual({ leadingText: `/interrogate [The user invoked this skill. Follow the instructions at: ${skillPath("interrogate")}] then /interrogate then /implement and /implement the fixes`, commandText: "/implement the fixes", - skillName: "implement", }); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 2524dcbb6c7d3..6ce141eec733e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -1,18 +1,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; /** - * Claude Code's only user-side invocation is a text block whose first character - * is `/`: the harness expands `/name args` into the SKILL.md body, and every - * character after the name (newlines included) arrives as `ARGUMENTS`. Only one - * skill expands per message; a second `/x` becomes argument text - * ([#87113](https://github.com/anthropics/claude-code/issues/87113)). + * Claude expands one skill per message only when `/name` starts the text block. + * Everything after the name, including newlines or a second `/x`, becomes `ARGUMENTS`. + * https://github.com/anthropics/claude-code/issues/87113 */ -/** - * Same token shape the composer and timeline chips recognise - * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a - * dispatched skill are always the same set. - */ +/** Match packages/shared/src/composerInlineTokens.ts so displayed chips and dispatched skills accept the same tokens. */ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; @@ -23,7 +17,6 @@ export interface ClaudeSkillDispatch { readonly leadingText: string | undefined; /** `/name` plus the trailing text, ready to be the message's last text block. */ readonly commandText: string; - readonly skillName: string; } export function planClaudeSkillDispatch( @@ -61,6 +54,5 @@ export function planClaudeSkillDispatch( leadingText: prompt.slice(0, last.start).trim().length > 0 ? promptWithInlineSlashes : undefined, commandText: `/${last.name}${prompt.slice(last.end)}`.trimEnd(), - skillName: last.name, }; } diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 62c322cabf120..2897cc0b84282 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -235,7 +235,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { { leadingText: undefined, commandText: "/plugin:alias focus on auth", - skillName: "plugin:alias", }, ); assert.deepEqual( @@ -382,7 +381,6 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { { leadingText: undefined, commandText: "/demo", - skillName: "demo", }, ); yield* writeSkill( @@ -504,9 +502,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); - // A skill with no frontmatter falls back to its directory name; a skill - // whose frontmatter fails to parse is skipped entirely (Claude Code - // won't load it either). + // Claude falls back to directory names without frontmatter, but skips malformed frontmatter. assert.deepEqual( skills.map((skill) => skill.name), ["no-frontmatter"], @@ -561,8 +557,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const workspace = path.join(tempDir, "workspace"); yield* fs.makeDirectory(workspace, { recursive: true }); - // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own - // cwd (the workspace), so discovery must do the same. + // The CLI resolves relative CLAUDE_CONFIG_DIR against workspace cwd. yield* writeSkill( path.join(workspace, "relative-config", "skills"), "relative-skill", @@ -684,8 +679,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), ); } - // Verified against the CLI: with an unknown string or a boolean in the - // map, the valid "off" sibling is ignored too and every skill runs. + // Claude ignores the entire override map when any value is an unknown string or boolean. yield* fs.writeFileString( path.join(configDir, "settings.json"), '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index ff26287611812..56c2ff19bebdf 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -33,11 +33,7 @@ type SkillFrontmatter = readonly userInvocable?: boolean; }; -/** - * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, - * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and - * numbers. - */ +/** Claude accepts YAML 1.1 booleans that this YAML 1.2 parser leaves as strings or numbers. */ function parseFrontmatterBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { @@ -71,8 +67,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { try { parsed = parseYamlDocument(frontmatter); } catch { - // Claude Code accepts plain scalars containing `: `. Repair only those, - // leaving comments and YAML structure for the full-document parser. + // Repair Claude's accepted ': ' scalars without disturbing comments or YAML structure. const repaired = frontmatter.replace( /^([\w-]+:[ \t]*)([^\r\n]*)/gm, (line, prefix: string, value: string) => { @@ -424,11 +419,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct if (homePath.length > 0) { return path.resolve(expandHomePath(homePath, home)); } - // No tilde expansion here: the spawned CLI receives this env var verbatim - // (env vars are never shell-expanded), so a literal `~` must stay literal - // for discovery to scan the same directory the runtime would. A relative - // value is resolved against the workspace cwd — the subprocess's own cwd — - // for the same reason. + // Match the spawned CLI: preserve a literal ~ and resolve relative config against workspace cwd. const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); @@ -436,11 +427,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -/** - * Roots are listed highest precedence first and the first hit for a name wins, - * matching Claude Code: verified against the CLI with the same skill name in - * both scopes, the user copy is the one that runs. - */ +/** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, cwd?: string, @@ -455,7 +442,6 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - // Use the session's argument parser so discovery sees the same --add-dir value. const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index db8f42faea7f0..90e37cc66712b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -254,12 +254,8 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.runtimePaths?.shadowHomePath).toContain( `providers/codex/${instanceId}/shadow`, ); - // The fixture app-server cannot start, so the workspace scan fails - // instead of passing the machine snapshot off as the workspace's skills. const scan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe(Effect.exit); expect(Exit.isFailure(scan)).toBe(true); - // The account is still signed in, so a runtime that cannot start is a - // failed scan too, not the machine snapshot's inventory. failAcquire = true; const runtimeScan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe( Effect.exit, @@ -299,8 +295,6 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(after.installed).toBe(true); expect(after.models).toEqual([]); expect(Option.isNone(yield* store.get)).toBe(true); - // Signed out, there is no runtime to scan with, so the machine - // snapshot's empty inventory stands instead of a failed scan. const signedOutScan = yield* instance.snapshotForCwd!(serverConfig.stateDir); expect(signedOutScan.skills).toEqual([]); }).pipe( @@ -463,9 +457,6 @@ it.layer(layerTest)("CodexDriver", (it) => { ); } - for (const {} of ["direct", "wrapper"] as const) { - } - it.effect.each([ { name: "conventional shim", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5462b13867b71..2d078cf32594f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -64,12 +64,7 @@ function isCodexStandaloneCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/packages/standalone/"); } -/** - * `codex update` replaces the standalone tree under `CODEX_HOME`. That tree - * lives in the shared home even when an auth-overlay shadow home is in use - * (the overlay only carries auth and a few local entries), so the updater - * runs against `sharedHomePath` rather than the instance's effective home. - */ +/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, @@ -286,13 +281,9 @@ export const CodexDriver: ProviderDriver< ), ); - // Redemption spends something on the user's account. It serialises on - // the account (instances sharing a Codex home share the credit), keeps - // one idempotency key until Codex reports an outcome, and is bounded so - // a hung app-server cannot hold the account lock. - // Keyed on the directory holding auth.json: an auth-overlay instance has - // its own account under `effectiveHomePath`, while plain instances share - // the common home. The continuation key would conflate the two. + // Redemption spends account credit; serialize by the auth.json directory, keep one idempotency key + // until the outcome, and bound the wait so a hung app-server cannot hold the account lock. + // Auth overlays use effectiveHomePath; the continuation key would conflate them with shared homes. const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const consumeResetCredit: NonNullable = () => resetCreditCoordinator diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 093777e67db5d..2bbcea20faa91 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -121,8 +121,10 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { '{"models":["shared"]}\n', ); yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + yield* writeTextFile(path.join(sharedHome, "auth.lock"), ""); yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* writeTextFile(path.join(shadowHome, "auth.lock"), ""); yield* fileSystem.symlink( path.join(sharedHome, "models_cache.json"), path.join(shadowHome, "models_cache.json"), @@ -149,6 +151,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { .readLink(path.join(shadowHome, "auth.json")) .pipe(Effect.result); const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + const authLockLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "auth.lock")) + .pipe(Effect.result); expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); @@ -156,6 +161,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); + expect(authLockLinkResult._tag).toBe("Failure"); }), ); @@ -221,6 +227,40 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "shares local thread writer locks once no writer still holds one", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const shadowLocks = path.join(shadowHome, "thread-writer-locks"); + const threadLock = path.join(shadowLocks, "019a0000-0000-7000-8000-000000000000.lock"); + + yield* writeTextFile(path.join(shadowLocks, ".coordination.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, ".DS_Store"), ""); + yield* writeTextFile(threadLock, ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + expect(yield* fileSystem.exists(threadLock)).toBe(true); + + yield* fileSystem.remove(threadLock); + yield* materializeCodexShadowHome(layout); + expect(yield* fileSystem.readLink(shadowLocks)).toBe( + path.join(sharedHome, "thread-writer-locks"), + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "accepts Codex-created shadow-local runtime directories", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index b0bb7df4e2d46..f6d2ba9d44cf5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,14 +26,19 @@ const KNOWN_SHARED_DIRECTORIES = [ "cache", "logs", "mcp-oauth-locks", + "thread-writer-locks", ] as const; -const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); +// auth.lock guards the shadow home's private auth.json. +const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); -// Codex creates these runtime locks itself when it starts in a shadow home before the shared -// home has one. They guard shared state, so a local copy is replaced with the shared link. -const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]); +// Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. +const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ + "mcp-oauth-locks", + "thread-writer-locks", + ".sqlite-maintenance.lock", +]); function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -254,6 +259,30 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }); } + if (input.entryName === "thread-writer-locks") { + const lockNames = yield* input.fileSystem.readDirectory(link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readDirectory", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + // Codex deletes a thread lock on release, so one that is left may still guard a live + // writer. Keep the local directory until a later setup finds none. + if (lockNames.some((name) => name.endsWith(".lock") && name !== ".coordination.lock")) { + return yield* Effect.logWarning( + "Codex shadow home still has thread writer locks; it will share them on a later setup.", + { path: link }, + ); + } + } + yield* input.fileSystem.remove(link, { recursive: true }).pipe( Effect.catchTags({ PlatformError: (cause) => diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 1f4a238fa34c9..f9728f9a24973 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -235,8 +235,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ); yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe( Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)), - // Sign-out closes the scope of an in-flight probe, which fails that refresh - // with an interrupt. Keep listening so the sign-out's own idle still refreshes. + // Sign-out interrupts in-flight probes; keep listening for its subsequent idle refresh. Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))), Effect.forkScoped, ); @@ -301,10 +300,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu enabled ? resolveRuntime.pipe( Effect.matchEffect({ - // Signed out or not set up, there is nothing to scan with and the - // machine snapshot's empty inventory says as much. Otherwise the - // runtime failed for a signed-in account (a token refresh or - // reconnect) or before the first check, and the scan failed. + // Signed-out snapshots have an empty inventory; a signed-in runtime failure must fail the scan. onFailure: (cause) => snapshot.getSnapshot.pipe( Effect.filterOrFail( @@ -327,8 +323,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu }), Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - // Fail rather than return the machine snapshot: the registry - // publishes any returned snapshot as this workspace's inventory. + // Fail instead of returning machine inventory, which the registry would publish as workspace inventory. Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts index 7980c8d0fad07..a08eac31746ff 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -162,6 +162,36 @@ describe("readAntigravityUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("prices ACP effort variants at the base model rate", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "effort.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, name] of ["gemini-4-argon-high", "gemini-4-argon"].entries()) { + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(1, protoText(19, name))), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))), + ]); + } + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual( + records(result).map(({ model, rateModel }) => [model, rateModel]), + [ + ["gemini-4-argon-high", "gemini-4-argon"], + ["gemini-4-argon", undefined], + ], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("merges Antigravity aliases that bridge previously separate step records", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 26db5d8d46f30..d3035d5ae9b5f 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -1,5 +1,6 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime, which the cache fingerprint needs. import * as NodeFSP from "node:fs/promises"; +import { pipe } from "effect/Function"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -101,6 +102,12 @@ const MODEL_IDS: Record = { 1047: "gemini-3-flash-preview", }; +// ACP slugs append the thinking level, which shares the base model's price. +function withRateModel(record: UsageRecord): UsageRecord { + const rateModel = record.model.replace(/-(?:low|medium|high)$/, ""); + return rateModel === record.model ? record : { ...record, rateModel }; +} + function modelName(name: string, id: number): string { if (name) { const normalized = name @@ -170,11 +177,7 @@ class AntigravityDatabaseDecodeError extends Schema.TaggedError(query, [after, pageRows]) .pipe(Effect.withTracerEnabled(false)); for (const row of rows) { - // Skipped here rather than in SQL, so a statement never scans past - // more than one page of steps that have no metadata. + // Filter outside SQL to bound scans to one page, including rows without metadata. if (step && row.data === null) continue; const idx = row.idx; if (typeof idx !== "number") { @@ -245,7 +245,9 @@ const readDatabase = Effect.fn("readDatabase")(function* ( for (const row of yield* sql.unsafe<{ readonly data: unknown }>( "SELECT data FROM trajectory_metadata_blob", )) { - trajectoryTimestamp ??= yield* decode(() => timestamp(nested(fields(blob(row.data)), 2))); + trajectoryTimestamp ??= yield* decode(() => + pipe(blob(row.data), fields, (message) => nested(message, 2), timestamp), + ); } } const steps = tables.has("steps") @@ -327,12 +329,7 @@ interface CachedDatabase { readonly candidates: readonly UsageCandidate[]; } -/** - * Parsed databases keyed by canonical path. An entry is reused only while both - * the database and its `-wal` sidecar keep the same size, mtime and ctime: new - * rows land in the WAL without touching the main file until a checkpoint, and - * ctime moves on any content write even when mtime is restored. - */ +/** Fingerprint canonical DB/WAL paths by size, mtime and ctime; WAL holds new rows, and ctime catches restored mtimes. */ export const makeAntigravityUsageCache = () => new Map(); export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* ( @@ -482,7 +479,7 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* } for (const [index, group] of groups.entries()) { if (group.parent === index && group.record.timestampMs >= sinceMs) { - files[group.fileIndex]!.records.push(group.record); + files[group.fileIndex]!.records.push(withRateModel(group.record)); } } return { files, errors }; diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 1db2197a53e55..a9ebb05a2e1e7 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -49,8 +49,7 @@ describe("parseClaudeLine", () => { }); it("gives every content block of one message the same dedupe key", () => { - // T3 Code writes one record per content block, each repeating the parent - // message's full usage. Summing them would overcount ~2.4x on real data. + // 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" })); diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 11f16631bca8f..d9ee4f4a04ff5 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -444,7 +444,7 @@ describe("parseCodexLine", () => { // A forked/subagent rollout opens with the parent's history copied in and // every line re-stamped to the fork instant, then the ancestors' session - // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + // metas. Counting those again multiplied usage ~1.85x on real data. describe("forked rollouts", () => { const meta = (overrides: { id: string; diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index d3fd0680e31c8..38615eb2cb0cb 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -98,12 +98,7 @@ export function initialCodexScanState(): CodexScanState { }; } -/** - * A forked or subagent rollout opens with the parent's full history copied in, - * every line re-stamped to the fork instant. Those copies are written in one - * synchronous burst (observed gaps 0-40ms), while the child's first genuine - * usage event only lands after a real model turn (observed 5s+). - */ +/** Forks copy and restamp parent history in a burst (0-40ms gaps); genuine child usage follows a model turn (5s+ observed). */ const FORK_COPY_MAX_GAP_MS = 1000; function isForkedSessionMeta(payload: Record): boolean { @@ -141,9 +136,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { - // Only the first meta describes this file's own session. A forked rollout - // repeats the ancestors' metas right after it; letting those through would - // reassign every subsequent record to an ancestor session. + // Only the first meta identifies this session; copied ancestor metas would misattribute later records. if (state.sawSessionMeta) return null; state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; @@ -302,10 +295,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | }; } -/** - * Codex omits the field when no tier was requested, which bills as standard, as - * do `default` and `standard`. `fast` is accepted as an alias of `priority`. - */ +/** Missing, default and standard tiers bill as standard; fast aliases priority. */ function codexSpeed(serviceTier: unknown): UsageSpeed { if (serviceTier === "priority" || serviceTier === "fast") return "fast"; if (serviceTier === "ultrafast") return "ultrafast"; @@ -332,8 +322,7 @@ export const codexUsageFormat: TranscriptUsageFormat = { info: { last_token_usage: true, total_token_usage: true }, }, }, - // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no - // usage, but they carry the model, tier and session the reducer needs. + // Metadata lines carry model, tier and session even without usage. usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index c932ac2fa5e5a..af94ede2e5894 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -16,7 +16,6 @@ import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { - makeEventNdjsonLogger, makeEventNdjsonLogStore, type PendingRecord, writeBatchedMessages, @@ -70,11 +69,10 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { circular.self = circular; try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); - assert.exists(logger); - if (!logger) return; + const store = yield* makeEventNdjsonLogStore(basePath); + const logger = store.logger("native"); yield* logger.write(circular, ThreadId.make("thread-1")); - yield* logger.close(); + yield* store.close(); const serialized = encodeUnknownJson(messages); assert.notInclude(serialized, secret); @@ -96,15 +94,12 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { - const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } + const store = yield* makeEventNdjsonLogStore(basePath); + const logger = store.logger("orchestration"); yield* logger.write({ id: "evt-no-thread" }, null); yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); - yield* logger.close(); + yield* store.close(); const globalPath = ownedLogPath(basePath, "_global"); assert.equal(NodeFS.existsSync(globalPath), true); @@ -344,14 +339,8 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { const basePath = NodePath.join(tempDir, "provider-canonical.ndjson"); try { - const logger = yield* makeEventNdjsonLogger(basePath, { - stream: "canonical", - batchWindowMs: 0, - }); - assert.notEqual(logger, undefined); - if (!logger) { - return; - } + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const logger = store.logger("canonical"); yield* Effect.all( [ @@ -360,7 +349,7 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { ], { concurrency: "unbounded" }, ); - yield* logger.close(); + yield* store.close(); const globalPath = ownedLogPath(basePath, "_global"); assert.equal(NodeFS.existsSync(globalPath), true); diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 6af7290b5f62d..00956a1bcffd4 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * Native and canonical views share batching, rotation, and retention state so - * they cannot race while appending to the same thread-scoped file. - */ +/** Native and canonical views share write state to avoid races on the same thread file. */ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -75,32 +72,9 @@ export interface EventNdjsonLogStoreOptions { readonly maxBytes?: number; readonly maxFiles?: number; readonly batchWindowMs?: number; - readonly maxTotalBytes?: number; - readonly maxAgeMs?: number; - readonly retentionCheckIntervalMs?: number; - readonly maxBufferedBytes?: number; - readonly maxBufferedRecords?: number; readonly attribution?: ResourceAttribution.ResourceAttribution["Service"]; } -export interface EventNdjsonLoggerOptions extends EventNdjsonLogStoreOptions { - readonly stream: EventNdjsonStream; -} - -export class EventNdjsonLogConfigurationError extends Schema.TaggedError()( - "EventNdjsonLogConfigurationError", - { - filePath: Schema.String, - option: Schema.String, - value: Schema.Number, - minimum: Schema.Number, - }, -) { - override get message(): string { - return `Provider event log option '${this.option}' must be an integer >= ${this.minimum}; received ${this.value} for '${this.filePath}'`; - } -} - export class EventNdjsonLogDirectoryError extends Schema.TaggedError()( "EventNdjsonLogDirectoryError", { @@ -113,9 +87,7 @@ export class EventNdjsonLogDirectoryError extends Schema.TaggedError= input.minimum) return undefined; - return new EventNdjsonLogConfigurationError(input); -} - -function resolveOptions( - filePath: string, - options: EventNdjsonLogStoreOptions, -): Effect.Effect { +function resolveOptions(options: EventNdjsonLogStoreOptions): Effect.Effect { const resolved = { maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES, batchWindowMs: options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS, - maxTotalBytes: options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, - maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS, - retentionCheckIntervalMs: - options.retentionCheckIntervalMs ?? DEFAULT_RETENTION_CHECK_INTERVAL_MS, - maxBufferedBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES, - maxBufferedRecords: options.maxBufferedRecords ?? DEFAULT_MAX_BUFFERED_RECORDS, + maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES, + maxAgeMs: DEFAULT_MAX_AGE_MS, + retentionCheckIntervalMs: DEFAULT_RETENTION_CHECK_INTERVAL_MS, + maxBufferedBytes: DEFAULT_MAX_BUFFERED_BYTES, + maxBufferedRecords: DEFAULT_MAX_BUFFERED_RECORDS, attribution: options.attribution, } satisfies ResolvedOptions; - const validations = [ - ["maxBytes", resolved.maxBytes, 1], - ["maxFiles", resolved.maxFiles, 1], - ["batchWindowMs", resolved.batchWindowMs, 0], - ["maxTotalBytes", resolved.maxTotalBytes, 1], - ["maxAgeMs", resolved.maxAgeMs, 1], - ["retentionCheckIntervalMs", resolved.retentionCheckIntervalMs, 1], - ["maxBufferedBytes", resolved.maxBufferedBytes, 1], - ["maxBufferedRecords", resolved.maxBufferedRecords, 1], - ] as const; - - for (const [option, value, minimum] of validations) { - const error = validateOption({ filePath, option, value, minimum }); - if (error) return Effect.fail(error); - } return Effect.succeed(resolved); } @@ -648,7 +590,7 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( > { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const resolved = yield* resolveOptions(filePath, options); + const resolved = yield* resolveOptions(options); const directory = NodePath.dirname(filePath); const filePrefix = providerLogPrefix(filePath); @@ -810,22 +752,3 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( return { filePath, logger, close } satisfies EventNdjsonLogStore; }); - -export const makeEventNdjsonLogger = Effect.fnUntraced(function* ( - filePath: string, - options: EventNdjsonLoggerOptions, -): Effect.fn.Return< - ProviderEventLoggers.EventNdjsonLogger | undefined, - never, - FileSystem.FileSystem | Path.Path -> { - const store = yield* makeEventNdjsonLogStore(filePath, options).pipe( - Effect.catch((error) => - logWarning(error.message, { error }).pipe( - Effect.as(undefined), - ), - ), - ); - if (!store) return undefined; - return { ...store.logger(options.stream), close: store.close }; -}); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 373d9773280a0..4ecc0ad28971b 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -304,7 +304,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { ); // An expired access token prints the logged-out banner, then the CLI refreshes it in the - // same run, so the next `grok models` reports the login (#14285). + // same run, so the next `grok models` reports the login. it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( @@ -352,6 +352,27 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect("warns instead of reporting ready when no models are discovered", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: "You are logged in with grok.com.\n", + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("did not list any models"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + }), + ); + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 560b702db3aa9..d08ce0540e17e 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind } from "@t3tools/contracts"; +import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -13,6 +14,87 @@ import * as ServerSettings from "../serverSettings.ts"; import * as ModelManifest from "./ModelManifest.ts"; describe("resolveProviderCatalog", () => { + it.each([ + { releasedAt: "2026-01-01", now: "2026-01-01T00:00:00Z", badge: "new" }, + { releasedAt: "2026-01-01", now: "2026-01-07T23:59:59.999Z", badge: "new" }, + { releasedAt: "2026-01-01", now: "2026-01-08T00:00:00Z", badge: undefined }, + { releasedAt: "2026-01-01", now: "2026-02-01T00:00:00Z", badge: undefined }, + { releasedAt: "2026-01-02T00:00:00Z", now: "2026-01-01T00:00:00Z", badge: undefined }, + { releasedAt: undefined, now: "2026-01-01T00:00:00Z", badge: undefined }, + { releasedAt: "invalid", now: "2026-01-01T00:00:00Z", badge: undefined }, + ])("derives the new badge from releasedAt=$releasedAt at $now", ({ releasedAt, now, badge }) => { + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + synthetic: { + profiles: {}, + models: [ + { + slug: "model-next", + name: "Model Next", + status: "current", + ...(releasedAt === undefined ? {} : { releasedAt }), + }, + { + slug: "model-legacy", + name: "Model Legacy", + status: "legacy", + releasedAt: now, + }, + ], + }, + }, + }; + const catalog = ModelManifest.resolveProviderCatalog( + manifest, + ProviderDriverKind.make("synthetic"), + Date.parse(now), + ); + assert.strictEqual(catalog?.models[0]?.badge, badge); + assert.strictEqual(catalog?.models[1]?.badge, undefined); + }); + + it.effect("expires badges when rereading a cached manifest without a refresh", () => { + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + providers: { + synthetic: { + profiles: {}, + models: [ + { + slug: "model-next", + name: "Model Next", + status: "current", + releasedAt: "1970-01-01T00:00:00Z", + }, + ], + }, + }, + }; + return Effect.gen(function* () { + const service = yield* ModelCatalog.ModelCatalog; + const readCatalog = service.current(ProviderDriverKind.make("synthetic")); + assert.strictEqual((yield* readCatalog)?.models[0]?.badge, "new"); + yield* TestClock.adjust("7 days"); + assert.strictEqual((yield* readCatalog)?.models[0]?.badge, undefined); + }).pipe( + Effect.provide( + ModelManifest.layerModelCatalog.pipe( + Layer.provide( + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.die("Catalog reads must not refresh the manifest"), + forceRefresh: Effect.die("Catalog reads must not refresh the manifest"), + refreshInBackground: Effect.die("Catalog reads must not refresh the manifest"), + }), + ), + ), + ), + ); + }); + it("rejects invalid catalog references", () => { const invalidCatalog = (input: { readonly models: NonNullable[string]["models"]; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 27cd700e1a20b..7f9f3f3fd0ebe 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -26,11 +27,11 @@ const MODEL_MANIFEST_URL = const MANIFEST_TTL_MS = 60 * 60 * 1000; -/** Minimum gap between fetch attempts after a failure, so an offline server - * does not pay a network timeout on every provider check. */ +/** Back off failed fetches so offline health checks do not each wait on the network. */ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; +const MODEL_NEW_BADGE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; const ManifestModelStatus = Schema.Literals(["current", "legacy"]); @@ -46,7 +47,8 @@ const ManifestProviderModel = Schema.Struct({ subProvider: Schema.optional(TrimmedNonEmptyString), aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), status: ManifestModelStatus, - badge: Schema.optional(Schema.Literal("new")), + /** ISO date or timestamp of the model's public release, as published by its provider. */ + releasedAt: Schema.optional(Schema.String), profile: Schema.optional(TrimmedNonEmptyString), adapter: Schema.optional(Schema.Unknown), }); @@ -63,11 +65,7 @@ const ManifestProviderCatalog = Schema.Struct({ const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), - /** - * A release bundles its manifest, and a disk cache of an older edit must not - * outrank it. Optional so older remote files still decode; they count as - * older than any dated bundle. - */ + /** Older disk caches must not override bundled manifests; absent dates rank below any dated bundle. */ updatedAt: Schema.optional(Schema.String), compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), @@ -115,6 +113,7 @@ function manifestUpdatedAtMs(manifest: ModelManifestData): number { export function resolveProviderCatalog( manifest: ModelManifestData, driverKind: ProviderDriverKind, + nowMs = DateTime.toEpochMillis(DateTime.nowUnsafe()), ): ModelCatalog.ProviderCatalog | null { const catalog = manifest.providers?.[driverKind]; if (!catalog) return null; @@ -128,13 +127,19 @@ export function resolveProviderCatalog( const profile = entry.profile ? catalog.profiles[entry.profile] : undefined; if (entry.profile && !profile) return null; + const releasedAtMs = entry.releasedAt === undefined ? NaN : Date.parse(entry.releasedAt); + const showNewBadge = + entry.status === "current" && + nowMs >= releasedAtMs && + nowMs - releasedAtMs < MODEL_NEW_BADGE_DURATION_MS; + models.push({ slug: entry.slug, name: entry.name, ...(entry.shortName ? { shortName: entry.shortName } : {}), ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), ...(entry.aliases ? { aliases: entry.aliases } : {}), - ...(entry.badge ? { badge: entry.badge } : {}), + ...(showNewBadge ? { badge: "new" as const } : {}), status: entry.status, capabilities: profile?.capabilities ?? null, adapter: entry.adapter, @@ -169,9 +174,7 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; readonly refresh: Effect.Effect; readonly forceRefresh: Effect.Effect; - /** Drivers call this from - * provider checks: the fetch is process-shared state, so it must survive - * the teardown of whichever instance happened to trigger it. */ + /** Process-shared fetches must survive the triggering instance's teardown. */ readonly refreshInBackground: Effect.Effect; } >()("t3/provider/ModelManifest") {} @@ -199,9 +202,7 @@ export const make = Effect.gen(function* () { let lastAttemptMs: number | null = null; const refreshSemaphore = yield* Semaphore.make(1); - // `Effect.cached` makes concurrent first readers await the same disk load - // rather than racing a "loaded" flag. Only `refreshed` takes the fetch - // semaphore; `current` must never wait behind an in-flight network refresh. + // Only refresh takes the fetch semaphore; current must not wait on network I/O. const ensureDiskCacheLoaded = yield* Effect.cached( Effect.gen(function* () { const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( @@ -209,8 +210,6 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); if (fromDisk === null) return; - // Comparing edit dates, not fetch time, keeps this independent of when - // the cache was written relative to the release. if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { return; } @@ -222,9 +221,7 @@ export const make = Effect.gen(function* () { const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; - // A timestamp in the future means the wall clock moved backwards (the - // disk cache crosses restarts, so monotonic time cannot cover it). Treat - // it as expired: the refetch rewrites both timestamps and self-heals. + // Expire future timestamps after clock rollback; disk caches cross restarts, ruling out monotonic time. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; @@ -243,8 +240,7 @@ export const make = Effect.gen(function* () { Effect.timeout(FETCH_TIMEOUT_MS), Effect.catchCause(() => Effect.succeed(null)), ); - // A CDN can still serve an earlier edit after a release. Apply the same - // freshness rule as the disk cache so it cannot undo bundled version gates. + // Reject stale CDN edits just like stale disk caches to preserve bundled version gates. if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) { return manifest; } @@ -281,10 +277,11 @@ export const layerModelCatalog = Layer.effect( Effect.gen(function* () { const manifest = yield* ModelManifest; return ModelCatalog.ModelCatalog.of({ - current: (driverKind) => - manifest.current.pipe( - Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined), - ), + current: Effect.fn("ModelCatalog.current")(function* (driverKind: ProviderDriverKind) { + const data = yield* manifest.current; + const nowMs = yield* Clock.currentTimeMillis; + return resolveProviderCatalog(data, driverKind, nowMs) ?? undefined; + }), bundled: (driverKind) => resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined, refreshInBackground: manifest.refreshInBackground, diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts index a5f8ff25aad56..5023dc5bf47ef 100644 --- a/apps/server/src/provider/ProviderAuthService.test.ts +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -281,13 +281,11 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push(`stop:${providerSessionId}`); yield* input.beforeStop ?? Effect.void; if (input.stopError) { - return yield* Effect.fail( - new ProviderSessionManager.ProviderSessionReleaseError({ - providerSessionId, - reason, - cause: new Error("private process diagnostics"), - }), - ); + return yield* new ProviderSessionManager.ProviderSessionReleaseError({ + providerSessionId, + reason, + cause: new Error("private process diagnostics"), + }); } released.push(providerSessionId); for (const [threadId, list] of sessions) { diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 5a730c5baf8d4..ed841fde70a66 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -12,7 +12,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; -import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -88,9 +87,7 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); - // Native sessions may still belong to the previous provider after the - // selected model changes. Read session bindings, not the selected model, - // when invalidating credentials for sign-in or sign-out. + // Invalidate credentials using session bindings; model selection may already point to another provider. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, binding: ProviderAuthController["credentialBinding"], @@ -208,8 +205,8 @@ export const makeProviderAuthService = Effect.gen(function* () { }); return yield* auth.reconnectProfile(input.methodId); }), - importProfile: (input) => - credentialChanges.withPermit( + importProfile: Effect.fn("ProviderAuthService.importProfile")(function (input) { + return credentialChanges.withPermit( Effect.gen(function* () { const auth = yield* getController(input.instanceId, "import"); yield* checkSharedBinding(input.instanceId, "start", auth); @@ -224,7 +221,8 @@ export const makeProviderAuthService = Effect.gen(function* () { stopSessions(input.instanceId, auth.credentialBinding), ); }), - ), + ); + }), start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { return yield* credentialChanges.withPermit( Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 5c164f75d8520..8d73d697705fc 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -2,6 +2,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -9,6 +10,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as DocumentText from "./DocumentText.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; export const layer = Layer.effect( @@ -19,6 +21,7 @@ export const layer = Layer.effect( const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -36,8 +39,15 @@ export const layer = Layer.effect( shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), - credentials: (namespace, bindingId) => - ProviderCredentialStore.make(namespace, bindingId).pipe( + // DocumentText owns the path-resolution span. + resolveExtractedTextPath: Effect.fnUntraced( + function* (path) { + return yield* DocumentText.resolveExtractedTextPath(path); + }, + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + credentials: Effect.fn("ProviderHost.credentials")(function (namespace, bindingId) { + return ProviderCredentialStore.make(namespace, bindingId).pipe( Effect.map((store) => ({ binding: store.binding, get: store.get.pipe( @@ -59,7 +69,8 @@ export const layer = Layer.effect( })), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(Crypto.Crypto, crypto), - ), + ); + }), }); }), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 48711552b43c0..de8fc1deae452 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -22,7 +22,6 @@ 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 Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; @@ -36,6 +35,7 @@ import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistry.ts"; import * as ProviderOrchestrationAdapterInfrastructure from "./ProviderOrchestrationAdapterInfrastructure.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -55,7 +55,6 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, snapshot: Effect.succeed({ hostPower: { source: "unknown", @@ -75,8 +74,6 @@ const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPol shouldRunOpportunisticWork: true, updatedAt: TEST_EPOCH, }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(true), shouldRunScopeWork: () => Effect.succeed(true), shouldRunOpportunisticWork: Effect.succeed(true), }); @@ -191,6 +188,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ); const layerBase = ProviderHostLive.layer.pipe(Layer.provideMerge(layerBaseDeps)); const layerTest = ProviderOrchestrationAdapterInfrastructure.layer.pipe( + Layer.provide(PortScanner.layerTest), Layer.provideMerge(layerBase), ); @@ -285,8 +283,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { [claudeId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, - // Packaged desktop builds start the server in $HOME, where some - // `claude` wrappers refuse to run. + // Packaged desktop starts in $HOME, where some claude wrappers refuse to run. environment: [{ name: "T3_CLAUDE_REQUIRED_CWD", value: stateDir, sensitive: false }], config: makeClaudeConfig({ enabled: true, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 1659e8f8fa144..3bbbe2900b9f2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -29,14 +29,7 @@ export class ProviderInstanceRegistry extends Context.Service< ) => Effect.Effect; readonly listInstances: Effect.Effect>; readonly listUnavailable: Effect.Effect>; - /** - * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the stream starts - * running, forking a consumer via `Stream.runForEach(...).pipe(Effect.forkScoped)` races - * the next publish — the forked fiber may not have subscribed yet when the publish lands. - * Hot-reload consumers that must not miss a publish should use `subscribeChanges` below - * instead, which acquires the subscription synchronously in the caller's fiber before the - * consumer loop is forked. - */ + /** streamChanges subscribes on consumption; use subscribeChanges before forking to avoid missing the next publish. */ readonly streamChanges: Stream.Stream; readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } @@ -57,11 +50,7 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); -/** - * An explicit false on either the envelope or the raw config blob wins (most - * restrictive) — old settings files can carry both flags with conflicting - * values, and a user's disable must never be silently undone. - */ +/** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); if (entry.enabled === false || rawConfigEnabled === false) { @@ -177,9 +166,7 @@ const makeReconcile = (input: { nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), ); - // 1. Close scopes for instances that disappeared or whose config - // changed. Do this BEFORE creating replacements so ids map 1-to-1 - // to live scopes at all times. + // Close changed/removed scopes before replacements to preserve one live scope per id. const removedIds: Array = []; const replacedIds = new Set(); for (const [instanceId, live] of previousEntries) { diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts index 029904eac5b67..c497e00ac17e9 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.test.ts @@ -5,9 +5,19 @@ import { ProviderInstanceId, type ServerSettings, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { describe, expect, it } from "vite-plus/test"; -import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; +import * as ServerConfig from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { + DEV_REPLAY_INSTANCE_ID, + deriveProviderInstanceConfigMap, + seedDevReplayInstance, +} from "./ProviderInstanceRegistryHydration.ts"; const projectId = ProjectId.make("project-a"); const instanceId = ProviderInstanceId.make("codex_work"); @@ -82,3 +92,52 @@ describe("deriveProviderInstanceConfigMap", () => { expect(merged[instanceId]).toBe(settings.providerInstances[instanceId]); }); }); + +describe("seedDevReplayInstance", () => { + const seed = (devUrl: URL | undefined) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + yield* seedDevReplayInstance().pipe( + Effect.provideService(ServerConfig.ServerConfig, { ...config, devUrl }), + ); + const settings = yield* ServerSettingsService; + return (yield* settings.getSettings).providerInstances[DEV_REPLAY_INSTANCE_ID]; + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-dev-replay-seed-" }), + ServerSettingsService.layerTest({ + providerInstances: { + [DEV_REPLAY_INSTANCE_ID]: { + driver: ProviderDriverKind.make("acpRegistry"), + displayName: "My replay", + enabled: false, + config: { source: "local", commandPath: "/old/node" }, + }, + }, + }), + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ); + + effectIt.effect("leaves settings alone outside dev", () => + seed(undefined).pipe( + Effect.map((instance) => + expect(instance?.config).toEqual({ source: "local", commandPath: "/old/node" }), + ), + ), + ); + + effectIt.effect("points a dev server at the replay agent and keeps user edits", () => + seed(new URL("http://localhost:5733")).pipe( + Effect.map((instance) => { + expect(instance?.displayName).toBe("My replay"); + expect(instance?.enabled).toBe(false); + expect(instance?.config).toMatchObject({ + source: "local", + commandArgs: [expect.stringMatching(/scripts[/\\]dev-replay-agent\.ts$/)], + }); + }), + ), + ); +}); diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 1a722be8e3ec0..cb778f1638a22 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -1,14 +1,18 @@ import { defaultInstanceIdForDriver, + ProviderDriverKind, + ProviderInstanceId, providerInstanceConfigEnabledFlag, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceConfigMap, - type ProviderInstanceId, ServerSettings, } from "@t3tools/contracts"; +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 Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as Settings from "../serverSettings.ts"; @@ -20,7 +24,8 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as ProviderHostLive from "./ProviderHostLive.ts"; import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import type * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; -import type * as ServerConfig from "../config.ts"; +import * as ServerConfig from "../config.ts"; +import type * as PortScanner from "../preview/PortScanner.ts"; type ProviderInstanceRegistryHydrationEnv = | Exclude< @@ -31,7 +36,11 @@ type ProviderInstanceRegistryHydrationEnv = > | Settings.ServerSettingsService | BackgroundPolicy.BackgroundPolicy - | ServerConfig.ServerConfig; + | ServerConfig.ServerConfig + | Path.Path + | FileSystem.FileSystem + // The Claude runner counts its CLIs as running agents for port discovery. + | PortScanner.PortDiscovery; export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, @@ -45,9 +54,7 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind }; } - // A project that turns an instance on needs it alive and probed even when the machine itself - // has it off, or a thread in that project would route to an instance nobody ever checked in - // on. + // Probe project-enabled instances even if disabled machine-wide; project threads can still route to them. for (const overrides of Object.values(settings.projectSettingsOverrides)) { for (const [rawInstanceId, projectOverride] of Object.entries( overrides?.providerInstances ?? {}, @@ -69,6 +76,47 @@ export const deriveProviderInstanceConfigMap = ( return merged as ProviderInstanceConfigMap; }; +export const DEV_REPLAY_INSTANCE_ID = ProviderInstanceId.make("devReplay"); + +/** + * Dev servers run from source keep a free ACP agent that replays recorded turns, so UI checks + * need no paid model. Bundled servers, such as desktop dev, have no agent script and skip it. + */ +export const seedDevReplayInstance = Effect.fn( + "ProviderInstanceRegistryHydration.seedDevReplayInstance", +)(function* () { + const config = yield* ServerConfig.ServerConfig; + if (config.devUrl === undefined) return; + const path = yield* Path.Path; + const agentPath = yield* path.fromFileUrl( + new URL("../../scripts/dev-replay-agent.ts", import.meta.url), + ); + if (!(yield* (yield* FileSystem.FileSystem).exists(agentPath))) return; + const commandPath = yield* HostProcess.ExecutablePath; + const serverSettings = yield* Settings.ServerSettingsService; + const existing = (yield* serverSettings.getSettings).providerInstances[DEV_REPLAY_INSTANCE_ID]; + const existingConfig = existing?.config as Record | undefined; + // Rewritten only when stale: the node and checkout paths can move. + if ( + existingConfig?.commandPath === commandPath && + (existingConfig.commandArgs as ReadonlyArray | undefined)?.[0] === agentPath + ) { + return; + } + // Keeps user edits, such as turning the provider off. + const instance: ProviderInstanceConfig = { + displayName: "Replay (dev)", + ...existing, + driver: ProviderDriverKind.make("acpRegistry"), + config: { ...existingConfig, source: "local", commandPath, commandArgs: [agentPath] }, + }; + yield* serverSettings.updateProviderInstance({ + operation: "upsert", + instanceId: DEV_REPLAY_INSTANCE_ID, + instance, + }); +}); + const layerSettingsWatcher = (settingsChanges: Stream.Stream) => Layer.effectDiscard( Effect.gen(function* () { @@ -99,6 +147,9 @@ export const layer: Layer.Layer< > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* Settings.ServerSettingsService; + yield* seedDevReplayInstance().pipe( + Effect.catchCause((cause) => Effect.logWarning("Dev replay agent unavailable", cause)), + ); // Subscribe before the seed read so saves during registry construction are queued. const settingsChanges = yield* serverSettings.subscribeChanges; const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index f7675ff33a38f..51322826230d8 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,9 +1,4 @@ -/** - * Closes the per-instance `Scope` of every removed or replaced entry (tearing - * down adapter processes, refresh fibres, temp files) BEFORE creating the - * replacement — `reconcile` guarantees "at most one live instance per id" at - * all times. - */ +/** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 266de2339137f..3245ee61294e4 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -16,11 +16,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; -/** - * `providerContinuationRequestsLayer` must be the same layer reference the - * orchestration runtime provides to its continuation worker so Effect layer - * memoization yields one shared queue. - */ +/** Reuse the continuation worker's exact layer reference so memoization shares one queue. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 31574419e1c91..64effe3895419 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -51,6 +51,7 @@ import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRunt import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderInstanceRegistryHydration from "./ProviderInstanceRegistryHydration.ts"; +import * as PortScanner from "../preview/PortScanner.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettingsModule from "../serverSettings.ts"; import { @@ -109,7 +110,6 @@ const layerTestHttpClient = Layer.succeed( const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, snapshot: Effect.succeed({ hostPower: { source: "unknown", @@ -129,8 +129,6 @@ const layerBackgroundPolicyAlwaysRun = Layer.mock(BackgroundPolicy.BackgroundPol shouldRunOpportunisticWork: true, updatedAt: TEST_EPOCH, }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(true), shouldRunScopeWork: () => Effect.succeed(true), shouldRunOpportunisticWork: Effect.succeed(true), }); @@ -660,8 +658,6 @@ it.layer( message: "Checking ACP authentication, models, and commands in the background...", models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], } satisfies ServerProvider; - // A saved sign-in confirmation marks the readiness snapshot authenticated - // before discovery has run. const confirmedCheckingProvider = { ...checkingProvider, auth: { status: "authenticated" }, @@ -695,6 +691,44 @@ it.layer( ); }); + it("drops Grok models a completed probe no longer advertises", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("grok"), + driver: ProviderDriverKind.make("grok"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-30T00:00:00.000Z", + version: "1.0.44", + models: [ + { slug: "grok-4.7", name: "Grok 4.7", isCustom: false, capabilities: null }, + { slug: "codexhub-removed", name: "Removed", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-09-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const acpFailedProvider = { + ...refreshedProvider, + status: "warning", + message: "Grok CLI is installed but ACP initialize failed.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, acpFailedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1248,6 +1282,98 @@ it.layer( ); }); + it.effect( + "a second request for a cwd being scanned gets its result, even if the first leaves", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + const scans = yield* Ref.make(0); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: () => + Ref.update(scans, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as(provider), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-shared-workspace-scan-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const refresh = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const first = yield* Effect.forkChild(refresh); + yield* Deferred.await(scanStarted); + const second = yield* Effect.forkChild(refresh, { startImmediately: true }); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(releaseScan, undefined); + assert.deepStrictEqual( + (yield* Fiber.join(second))[0]?.workspaceSnapshots?.map((snapshot) => snapshot.cwd), + ["/workspace"], + ); + assert.strictEqual(yield* Ref.get(scans), 1); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -1265,7 +1391,6 @@ it.layer( slashCommands: [COMPACT_SLASH_COMMAND], skills: [], } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. const unhealthyProvider = { ...healthyProvider, status: "error", @@ -1336,8 +1461,6 @@ it.layer( yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.slashCommands, [COMPACT_SLASH_COMMAND]); - // An expired scan runs while the account check fails: the skills are - // current, but commands that ride on the health check are unknown. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...unhealthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -1349,14 +1472,10 @@ it.layer( assert.strictEqual(provider?.status, "error"); assert.strictEqual(provider?.message, unhealthyProvider.message); - // A pending entry is rescanned on the next request; an empty scan - // removes the skills. yield* Ref.set(scanResult, { ...unhealthyProvider, skills: [] }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.skills, []); - // A driver that discovers its own commands reports them even while - // health is in error. const discoveredCommand = { name: "review", description: "Review changes" }; yield* Ref.set(scanResult, { ...unhealthyProvider, @@ -1368,7 +1487,6 @@ it.layer( assert.deepStrictEqual(discovered?.slashCommands, [discoveredCommand]); assert.strictEqual(discovered?.slashCommandsPending, undefined); - // Once health recovers, the next scan restores the full entry. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...healthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -1841,7 +1959,9 @@ it.layer( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ), Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), @@ -1957,7 +2077,9 @@ it.layer( const scope = yield* Scope.make(); yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const layerProviderRegistry = ProviderRegistry.layer.pipe( - Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + Layer.provideMerge( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ), Layer.provideMerge(AntigravityInstallation.AntigravityInstallation.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), @@ -2090,9 +2212,9 @@ it.layer( ), ); readSeed = false; - const services = yield* Layer.build(ProviderInstanceRegistryHydration.layer).pipe( - Effect.provide(dependencies), - ); + const services = yield* Layer.build( + ProviderInstanceRegistryHydration.layer.pipe(Layer.provide(PortScanner.layerTest)), + ).pipe(Effect.provide(dependencies)); failCurrentRead = input.failCurrentRead ?? false; yield* PubSub.publish(changes, undefined); yield* Deferred.succeed(consume, undefined); @@ -2150,8 +2272,6 @@ it.layer( describe("checkClaudeProviderStatus", () => { it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { - // The capability probe resolves for a logged-out CLI, so `tokenSource: - // "none"` is the only thing separating it from an authenticated one. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2250,8 +2370,6 @@ it.layer( it.effect("keeps an API key install authenticated when it reports no token source", () => Effect.gen(function* () { - // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that - // field alone would log the install out. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2275,10 +2393,6 @@ it.layer( it.effect("keeps a CLI that says nothing about its account authenticated", () => Effect.gen(function* () { - // Profile-authenticated installs report no token source at all, and a - // CLI too old to send an account payload reports nothing whatsoever. - // Only `tokenSource: "none"` disproves authentication; saying nothing - // is not the same as saying no. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed6..78b11306c5b6d 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -55,11 +56,7 @@ export class ProviderRegistry extends Context.Service< provider?: ProviderDriverKind, ) => Effect.Effect>; - /** - * When the instance id is unknown the call resolves with the currently - * cached list (no error) — matching the legacy `refresh` shim behaviour so - * transport layers don't have to special-case unknowns. - */ + /** Unknown instance ids return the cached list without error. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect>; @@ -154,9 +151,7 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver === ProviderDriverKind.make("pi")) { - // Pi reports known auth only after RPC inventory discovery completes. - // An empty inventory is warning/unauthenticated; failed or interactive - // discovery stays ready/unknown and must keep the last known models. + // Pi auth becomes known only after inventory discovery: empty means unauthenticated; failed/interactive discovery preserves unknown auth and cached models. return !( provider.installed && (provider.status === "ready" || provider.status === "warning") && @@ -165,13 +160,7 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => } if (provider.driver === ProviderDriverKind.make("acpRegistry")) { - // ACP Registry discovery probes return the agent's complete inventory, so - // a completed probe replaces the model list — otherwise agents that rename - // or collapse models leave stale entries pinned forever through the - // snapshot cache. Only a completed probe reports `nativeSessions`; - // readiness-only and failed probe snapshots only know the "default" - // placeholder and stay partial. Auth is no signal here: agents without - // sign-in stay "unknown" after every probe. + // Only completed ACP discovery provides nativeSessions and inventory; placeholder probes cannot establish auth for agents without sign-in. return !( provider.installed && provider.status === "ready" && @@ -179,6 +168,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } + if (provider.driver === ProviderDriverKind.make("grok")) { + // Grok reports ready only after it discovers a model list; the pending, + // ACP-failed, empty-inventory, and logged-out probes are warning/error. + return !(provider.installed && provider.status === "ready"); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { @@ -239,11 +234,7 @@ const mergeProviderModels = ( : mergedModels; }; -/** - * Antigravity's health check only initializes the agent, so after a server - * restart it reports the account as unchecked. The saved Google login still - * works, and the previous snapshot proves it. - */ +/** Antigravity health checks only initialize; preserve previously verified Google auth across restarts. */ const carrySavedAntigravityAccount = ( previousProvider: ServerProvider, nextProvider: ServerProvider, @@ -326,9 +317,7 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { return provider.instanceId; }; -// Each call re-captures the instance's `snapshot` closures, so after -// `ProviderInstanceRegistry` rebuilds an instance (e.g. because its settings -// changed), a fresh source rides the new PubSub instead of a closed one. +// Rebuilt instances have new PubSubs; capture their fresh closures instead of a closed source. const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, @@ -423,7 +412,7 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap>> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -540,8 +529,7 @@ export const layer = Layer.effect( }, ) { const providers = yield* upsertProviders([provider], options); - // Reclassify the current read model after fetching. Never republish the - // probe captured before the fetch: a newer health result may have landed. + // Reclassify the current model after fetch; republishing the old probe could overwrite newer health results. if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { yield* manifestService.refresh.pipe( Effect.andThen(upsertProviders([], { persist: false })), @@ -625,8 +613,7 @@ export const layer = Layer.effect( }, ); if (claimed.owner) { - // Detached so an interrupted first caller cannot cancel the pass that - // other callers are already awaiting. + // Detach shared passes so the first caller's interruption cannot cancel other callers' work. yield* getLiveSources.pipe( Effect.flatMap((sources) => Effect.forEach(sources, (source) => refreshOneSource(source), { @@ -743,9 +730,7 @@ export const layer = Layer.effect( provider: ProviderDriverKind, options?: { readonly fresh?: boolean }, ) { - // Read the instance registry, not `liveSubsRef`: the latter trails - // reconciliation, and an update must never run a retired instance's - // command against a freshly configured executable. + // liveSubsRef trails reconciliation and could run a retired instance's command; read the registry. const instance = yield* instanceRegistry.getInstance(instanceId); if (!instance || instance.driverKind !== provider) { return makeManualProviderMaintenanceCapabilities(provider); @@ -804,10 +789,7 @@ export const layer = Layer.effect( } } - // Fork long-lived subscriptions to each new/rebuilt instance's - // change stream before reading its current snapshot. If the - // driver's own initial probe finishes during this sync, either - // the current read or the active subscriber observes the result. + // Subscribe before reading so a probe completing mid-sync is observed by at least one path. for (const [, instance] of newlyAdded) { const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => @@ -943,29 +925,19 @@ export const layer = Layer.effect( ) { return providers; } - // Drivers spread their machine snapshot, whose `checkedAt` is the last - // health check. The TTL needs the time this scan started reading files. + // Use scan start for TTL, not checkedAt inherited from the machine health check. const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = Symbol(); - const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Map(current).set(input.cwd, token)); - return [true, next] as const; - }); - if (!claimed) return yield* Ref.get(providersRef); + const token = yield* Deferred.make(); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - return yield* refreshMachineSnapshot.pipe( + const runScan = refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), - // A failed scan fails this effect, so a returned snapshot is a real - // inventory even while the provider's health check reports an error. + // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { @@ -981,9 +953,7 @@ export const layer = Layer.effect( ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { ...scopedSnapshot, checkedAt: scannedAt, - // Unless the driver reports its own command discovery, - // commands come from the health check. A failed check - // leaves them unknown, so keep the last ones and retry. + // Without driver command discovery, retain previous commands after failed health checks and retry. slashCommandsPending: scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", }) @@ -1003,9 +973,33 @@ export const layer = Layer.effect( if (nextCwds.size) next.set(instance, nextCwds); else next.delete(instance); return next; - }), + }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), ), ); + // The claim and the fork happen together, so the token is always completed. The scan is + // detached so a disconnecting owner cannot cancel the result its waiters depend on. + const claim = yield* Effect.uninterruptible( + Effect.gen(function* () { + const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + const running = current?.get(input.cwd); + if (!input.fresh && running) return [running, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Map(current).set(input.cwd, token)); + return [undefined, next] as const; + }); + if (inFlight) return { inFlight } as const; + return { + // Starting now attaches the scan's `ensuring` before anything can close the scope. + scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), + } as const; + }), + ); + // Wait for the running scan: on-demand clients get its result only in this reply. + if ("inFlight" in claim) { + return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); + } + return yield* Fiber.join(claim.scan); }); return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 3385886629357..0c6bddbbf8567 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -66,12 +66,12 @@ describe("terminatePosixOwnedProcessTree", () => { expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/../escape")).toBe(false); expect(AcpSessionRuntime.isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); expect( - AcpSessionRuntime.parseCgroup2Mount( + AcpSessionRuntime.parseCgroup2Mounts( "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", - ), + )[0], ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); expect( - AcpSessionRuntime.parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n"), + AcpSessionRuntime.parseCgroup2Mounts("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")[0], ).toBeUndefined(); expect( AcpSessionRuntime.parseCgroup2Mounts( @@ -86,9 +86,7 @@ describe("terminatePosixOwnedProcessTree", () => { const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-")); - // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the - // test injects populated/remove while still exercising the real selection - // and path-safety logic against a real parent directory tree. + // Unlike cgroupfs, temp directories cannot rmdir with control files; inject populated/remove. const populatedByPath = new Map(); const writeSibling = (name: string, populated: "0" | "1" | null) => { const path = NodePath.join(parent, name); @@ -162,13 +160,12 @@ describe("terminatePosixOwnedProcessTree", () => { expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( bareGrok, ); - // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone - // (bareGrok lives only in cwd / PATH=scratch). + // Undefined PATH searches /usr/bin:/bin; the bareGrok fixture lives only in cwd. expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), ).toBeUndefined(); expect( - AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("sh", scratch, { PATH: undefined }), ).toBeDefined(); expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { @@ -290,8 +287,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, ); expect(missingTargetResult.status, missingTargetResult.stderr).toBe(125); - // CI's /bin/sh is dash, which runs the EXIT trap after a failed exec. Bash does not, - // so run the same wrapper under bash too. + // dash runs EXIT traps after failed exec; bash does not, so exercise both. const missingTargetBash = NodeChildProcess.spawnSync("bash", missingTarget.args, { encoding: "utf8", }); @@ -422,8 +418,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("rotates more than 64 live parents without scanning retained tombstones", () => Effect.gen(function* () { - // The server fixture's process group is the runner's real pid, so a synthetic - // parent sharing it would be skipped as the server's own group. + // Use a different synthetic parent group; the fixture shares the runner's real group, which cleanup skips. const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000; const parents = Array.from({ length: 130 }, (_, index) => identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index), @@ -647,7 +642,6 @@ describe("terminatePosixOwnedProcessTree", () => { grace: 0, rootPid: 100, }); - // Zombies remain visible but must not fail residual teardown. expect(fixture.processes.get(100)?.state).toBe("Z"); expect(fixture.processes.get(110)?.state).toBe("Z"); }), diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index b36c4afe91596..bd48ee767ee46 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -8,6 +8,7 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -196,6 +197,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: " Explain this image. ", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -216,6 +218,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [textAttachment], attachmentsDir: selectedEnvironment.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -246,6 +249,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Inspect the pasted text only as needed.", attachments: [pastedText], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); @@ -272,6 +276,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input, attachments: [attachment, pdfAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fixture.fs, @@ -287,6 +292,53 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect.each([undefined, {}, { image: false, audio: false, embeddedContext: false }])( + "keeps attachments path-only when native prompt capabilities are absent: %s", + (promptCapabilities) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const audio = { + ...textAttachment, + id: "unsupported-audio", + name: "audio.wav", + mimeType: "audio/wav", + }; + const gifAttachment = { + ...imageAttachment, + id: "unsupported-gif", + name: "animation.gif", + mimeType: "image/gif", + }; + const gif = yield* fixture.write(gifAttachment, "GIF89a"); + const image = yield* fixture.write(imageAttachment, "pixels"); + const pdf = yield* fixture.write(pdfAttachment, "%PDF-1.7"); + const text = yield* fixture.write(textAttachment, "hello"); + const sound = yield* fixture.write(audio, "RIFF"); + const input = `Read ${image.filePath}, ${gif.filePath}, ${pdf.filePath}, ${text.filePath}, and ${sound.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [imageAttachment, gifAttachment, pdfAttachment, textAttachment, audio], + attachmentsDir: fixture.attachmentsDir, + promptCapabilities, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => + Effect.die("Unnegotiated attachments must not be read").pipe(Stream.fromEffect), + }), + ); + expect(prompt).toEqual([{ type: "text", text: input }]); + expect( + yield* buildAntigravityPrompt({ + input: "Continue", + attachments: [], + attachmentsDir: fixture.attachmentsDir, + promptCapabilities, + }), + ).toEqual([{ type: "text", text: "Continue" }]); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); @@ -302,6 +354,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Transcribe this.", attachments: [audioAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -311,25 +364,27 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([ - { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, - ] satisfies ReadonlyArray)( - "rejects $name instead of silently dropping it from a valid prompt", - (attachment) => + it.effect.each([undefined, {}, { image: false }, { image: true }])( + "keeps unsupported image formats path-only with capabilities %s", + (promptCapabilities) => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); - yield* fixture.write(imageAttachment, new Uint8Array([1, 2, 3])); - const error = yield* buildAntigravityPrompt({ - input: "Analyze every attachment.", - attachments: [imageAttachment, attachment], + const attachment = { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }; + const upload = yield* fixture.write(attachment, "GIF89a"); + const input = `Analyze the image at ${upload.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [attachment], attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); + promptCapabilities, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => Effect.die("Unsupported images must not be read").pipe(Stream.fromEffect), + }), + ); - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining(`does not support '${attachment.name}'`), - }); + expect(prompt).toEqual([{ type: "text", text: input }]); }), ); @@ -342,6 +397,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Read this attachment.", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", @@ -367,6 +423,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }; const prompt = yield* buildAntigravityPrompt(input); expect(prompt).toEqual([ @@ -408,6 +465,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Inspect both attachments.", attachments: [pdfAttachment, imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", @@ -426,6 +484,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [pdfAttachment, textAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fixture.fs, @@ -459,6 +518,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [textAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -476,6 +536,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Read this image.", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 9ba55af7cf2ca..ac64c9da2a469 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -36,7 +36,6 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "transformSessionUpdate" | "transformStdout" > { - readonly agentDeviceEnvironment?: Readonly>; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; readonly clientFileSystem?: boolean; @@ -231,6 +230,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; readonly attachmentsDir: string; + readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; }): Effect.fn.Return< ReadonlyArray, EffectAcpErrors.AcpError, @@ -248,9 +248,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi attachment.type === "file" && "source" in attachment && attachment.source?._tag === "pasted-text"; - // ProviderService has already put the file path in the text block. Keep a - // folded clipboard paste lazy so the agent can search or sample it rather - // than paying to embed the entire resource in context immediately. + // The text already carries the path; keep folded pastes lazy so agents can search without embedding the whole file. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -261,12 +259,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi TEXT_MIME_TYPES.has(mimeType) || TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); const isPathOnly = - attachment.type === "file" && (isPastedText || (!audio && !pdf && !textFile)); - if (attachment.type === "image" && !image) { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, - ); - } + (attachment.type === "image" && (!image || input.promptCapabilities?.image !== true)) || + (attachment.type === "file" && + (isPastedText || + (audio + ? input.promptCapabilities?.audio !== true + : pdf || textFile + ? input.promptCapabilities?.embeddedContext !== true + : true))); const attachmentPath = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment, diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index b31b66149c7c4..4efbc28a80ce2 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -1,13 +1,11 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import type * as FileSystem from "effect/FileSystem"; import type * as Path from "effect/Path"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -/** - * The agent gates each write behind `session/request_permission`, so only - * path containment is checked here. - */ +/** 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 { @@ -15,10 +13,7 @@ function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -/** - * Symlinks resolve before the check, including the final component, so a link - * inside the workspace cannot read or write through to a file outside it. - */ +/** Resolve all symlinks, including the final component, before checking workspace containment. */ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFilePath")( function* (input: { readonly fileSystem: FileSystem.FileSystem; @@ -31,24 +26,27 @@ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFil const outside = EffectAcpErrors.AcpRequestError.invalidParams( `Path '${input.requestPath}' is outside the session workspace.`, ); - const real = yield* input.fileSystem.realPath(resolved).pipe( - Effect.catch(() => - Effect.gen(function* () { - // Only a missing file (a new write) falls back to its parent; a - // dangling or unreadable link must not be followed on write. - const entryExists = yield* input.fileSystem.readLink(resolved).pipe( - Effect.as(true), - Effect.catch(() => input.fileSystem.exists(resolved)), - Effect.orElseSucceed(() => true), - ); - if (entryExists) return yield* outside; - const parent = yield* input.fileSystem - .realPath(path.dirname(resolved)) - .pipe(Effect.orElseSucceed(() => path.dirname(resolved))); - return path.join(parent, path.basename(resolved)); - }), - ), - ); + // Canonicalize the deepest existing ancestor, so a link above missing directories cannot escape. + let candidate = resolved; + const missingSuffix: Array = []; + let real: string; + while (true) { + const canonical = yield* input.fileSystem.realPath(candidate).pipe(Effect.option); + if (Option.isSome(canonical)) { + real = path.join(canonical.value, ...missingSuffix); + break; + } + // Walk up only past missing entries, never dangling or unreadable links. + const entryExists = yield* input.fileSystem.readLink(candidate).pipe( + Effect.as(true), + Effect.catch(() => input.fileSystem.exists(candidate)), + Effect.orElseSucceed(() => true), + ); + const parent = path.dirname(candidate); + if (entryExists || parent === candidate) return yield* outside; + missingSuffix.unshift(path.basename(candidate)); + candidate = parent; + } const roots = yield* Effect.forEach(input.allowedRoots, (root) => input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)), ); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 20d084c4bf81b..192c8b43ffb14 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -5,17 +5,14 @@ import * as AcpWireSchema from "effect-acp/schema-v1"; import { extractAntigravityUserInputQuestion, - isAntigravityOpenCommand, antigravityApprovalOptions, antigravitySubagentOutput, - isAntigravitySubagentReplayStart, classifyAntigravitySubagentToolCall, isAntigravityUserInputRequest, makeAntigravityUserInputResponse, normalizeAntigravitySessionUpdate, normalizeAntigravityToolCall, sanitizeAntigravityToolPayload, - selectAntigravityPermissionOptionId, } from "./AntigravityProtocol.ts"; import { mergeToolCallState, @@ -53,21 +50,6 @@ describe("native Antigravity subagent tools", () => { }); it("recognizes history starts and bounds the launch output", () => { - expect( - isAntigravitySubagentReplayStart({ - update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." }, - }), - ).toBe(false); - expect( - isAntigravitySubagentReplayStart({ - update: { sessionUpdate: "tool_call", status: "completed" }, - }), - ).toBe(true); - expect( - isAntigravitySubagentReplayStart({ - update: { sessionUpdate: "tool_call_update", status: "completed" }, - }), - ).toBe(false); expect( antigravitySubagentOutput({ toolCallId: "trajectory:4", @@ -149,20 +131,6 @@ describe("Antigravity permissions and questions", () => { ], } satisfies EffectAcpSchema.RequestPermissionRequest; - it("uses only the option IDs offered for each approval decision", () => { - expect(selectAntigravityPermissionOptionId(permissionRequest, "accept")).toBe("run-this-time"); - expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptForSession")).toBe( - "remember-this-command", - ); - expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptAlways")).toBe( - "remember-this-command", - ); - expect(selectAntigravityPermissionOptionId(permissionRequest, "decline")).toBe( - "stop-this-command", - ); - expect(selectAntigravityPermissionOptionId(permissionRequest, "cancel")).toBeUndefined(); - }); - it("surfaces the agent's prompt injection warning on the remembered approval", () => { const risky = { ...permissionRequest, @@ -197,22 +165,8 @@ describe("Antigravity permissions and questions", () => { expect(antigravityApprovalOptions(permissionRequest)[1]).not.toHaveProperty("warning"); }); - it("does not replace unsupported remembered approval with a single approval", () => { - const request = { - ...permissionRequest, - options: permissionRequest.options.filter((option) => option.kind !== "allow_always"), - }; - expect(selectAntigravityPermissionOptionId(request, "acceptAlways")).toBeUndefined(); - expect(selectAntigravityPermissionOptionId(request, "acceptForSession")).toBeUndefined(); - expect(selectAntigravityPermissionOptionId(request, "accept")).toBe("run-this-time"); - expect( - selectAntigravityPermissionOptionId({ ...request, options: [] }, "decline"), - ).toBeUndefined(); - }); - it("routes the captured native question to single-choice input, not approval", () => { expect(isAntigravityUserInputRequest(questionRequest)).toBe(true); - expect(selectAntigravityPermissionOptionId(questionRequest, "accept")).toBeUndefined(); expect(extractAntigravityUserInputQuestion(questionRequest)).toEqual({ id: "interaction_9960062f", header: "Question", @@ -453,15 +407,4 @@ describe("Antigravity tool results", () => { expect(serialized).not.toContain("inline-image"); expect(serialized).not.toContain("inline-data-url"); }); - - it("tracks only commands that have passed approval and are still running", () => { - const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall); - expect(isAntigravityOpenCommand(running)).toBe(true); - expect(isAntigravityOpenCommand({ ...running, status: "pending" })).toBe(false); - expect(isAntigravityOpenCommand({ ...running, kind: "read" })).toBe(false); - const completed = normalizeAntigravityToolCall( - mergeToolCallState(running, parseToolUpdate(commandCompleted).toolCall), - ); - expect(isAntigravityOpenCommand(completed)).toBe(false); - }); }); diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 5c1ee06ee5f2e..7e2f24a31d3b9 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -1,5 +1,4 @@ import type { - ProviderApprovalDecision, ProviderApprovalOption, ProviderUserInputAnswers, UserInputQuestion, @@ -46,19 +45,6 @@ export function isAntigravityUserInputRequest( return request.toolCall.toolCallId.startsWith("interaction_"); } -export function selectAntigravityPermissionOptionId( - request: EffectAcpSchema.RequestPermissionRequest, - decision: ProviderApprovalDecision, -): string | undefined { - if (decision === "cancel" || isAntigravityUserInputRequest(request)) { - return undefined; - } - const kind = - decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always"; - const option = request.options.find((entry) => entry.kind === kind); - return option?.optionId.trim() ? option.optionId : undefined; -} - /** Copy truncated text so V8 cannot retain the original large string. */ const SECURITY_WARNING_META_KEY = "agy.security.warning"; const WARNING_TEXT_LIMIT = 512; @@ -69,10 +55,7 @@ const decodeSecurityWarning = Schema.decodeUnknownOption( }), ); -/** - * The agent marks "Allow Always" on shell and web tools with a prompt injection - * warning in `_meta`. Surface it as option text so both clients can show it. - */ +/** Expose shell/web Allow Always _meta warnings as option text for both clients. */ function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; @@ -231,6 +214,7 @@ function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: num export function sanitizeAntigravityToolPayload(payload: unknown): unknown { return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); } +const isJsonRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, @@ -259,9 +243,7 @@ export function normalizeAntigravitySessionUpdate( ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } : {}), ...(update.content !== undefined ? { content: content ?? [] } : {}), - ...(update._meta !== undefined - ? { _meta: Schema.is(Schema.Record(Schema.String, Schema.Json))(meta) ? meta : null } - : {}), + ...(update._meta !== undefined ? { _meta: isJsonRecord(meta) ? meta : null } : {}), }, }; } @@ -342,14 +324,7 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo }; } -export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { - return toolCall.kind === "execute" && toolCall.status === "inProgress"; -} - -/** - * ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs - * or models. - */ +/** ACP 1.1.1 reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, @@ -364,17 +339,6 @@ export function classifyAntigravitySubagentToolCall( return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent"; } -/** History sends a completed start before the separate result and its final status. */ -export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean { - const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined; - return ( - Predicate.isObject(update) && - update.sessionUpdate === "tool_call" && - update.status === "completed" && - (update.rawOutput === undefined || update.rawOutput === null) - ); -} - export function antigravitySubagentOutput(toolCall: AcpToolCallState): string | undefined { const output = toolCall.data.rawOutput; return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined; diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index be6174c33ffdb..7a25c8323174d 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -42,12 +42,7 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), ); -/** - * Call once when the driver starts, before it launches any process, so a previous server that was - * killed mid-session cannot leave unpacked runtimes behind. The system temp directory belongs to - * other programs and Windows does not lock data files, so sweeping it could gut a live - * extraction. - */ +/** Sweep only private runtimes before launch; Windows does not protect live extracted data files from deletion. */ export const removeAntigravityRuntimeTempDirs = Effect.fn("removeAntigravityRuntimeTempDirs")( function* (tempDirectory: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json new file mode 100644 index 0000000000000..f0b4ed278c788 --- /dev/null +++ b/apps/server/src/provider/agentContextBudget.json @@ -0,0 +1,5 @@ +{ + "claudeSystemAppendBytes": 8638, + "codexAdditionalContextBytes": 9157, + "mcpToolListJsonBytes": 57091 +} diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4e4d32ffc1075..5030b53005e89 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -51,9 +51,7 @@ const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSet const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); -// Python splits BROWSER on the platform path separator before it parses quotes. -// EPIPE must still exit 0 so Python does not fall back to an OS browser after -// cancellation. +// Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = `process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + @@ -172,10 +170,7 @@ export function isAntigravitySignInRequiredError(error: unknown): boolean { export interface AntigravityInstanceDirectories { readonly profile: string; - /** - * It sits beside the profile, not inside it: the agent unpacks members up to 120 characters deep, - * and the profile's longer name would push them past Windows' 260-character path limit. - */ + /** Keep beside the profile: 120-character archive members under its longer name would exceed Windows' 260-character limit. */ readonly runtimeTemp: string; } @@ -207,17 +202,14 @@ function antigravityEnvironment( // Windows treats environment keys as case-insensitive. Remove aliases too. if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; } - // The agent prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair goes through - // settings.json instead of the environment. + // Put the GCP pair in settings.json because GOOGLE_API_KEY takes precedence in the environment. const credential = auth.authMethod === "gemini-api-key" && auth.apiKey ? { GEMINI_API_KEY: auth.apiKey } : auth.authMethod === "agent-platform" && auth.apiKey ? { GOOGLE_API_KEY: auth.apiKey } : {}; - // It unpacks about 1 GB into the system temp directory per launch and a force - // kill leaves that behind. Point it at a T3-owned directory so the driver can - // reclaim the space. + // Each launch unpacks about 1 GB; use T3-owned temp so the driver can reclaim files after force-kill. const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory; return { ...environment, @@ -238,12 +230,7 @@ function antigravityEnvironment( }; } -/** - * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points - * at the private profile. Link the two skill directories back to the user's - * real `~/.gemini` so global skills load, while MCP servers, hooks, and - * credentials stay isolated. - */ +/** Private GEMINI_HOME isolates credentials/hooks/MCP; link global skills from the real ~/.gemini. */ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { readonly profileDirectory: string; readonly userHome: string; diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index d494c4924d0e0..7c0eeabb0a755 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -146,7 +146,6 @@ describe("Antigravity subscription limits", () => { expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56); yield* probe; expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - // Both quota endpoints are read on every probe. expect( requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), ).toHaveLength(4); @@ -157,8 +156,7 @@ describe("Antigravity subscription limits", () => { Effect.gen(function* () { const { probe } = yield* fixture({ summaryByHost: { - // The daily endpoint has answered with only the Claude/GPT group; the - // other endpoint returns the full default summary. + // The daily endpoint can omit Gemini while the other endpoint returns all groups. "daily-cloudcode-pa.googleapis.com": { groups: [ { @@ -181,8 +179,6 @@ describe("Antigravity subscription limits", () => { ]); const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); - // When endpoints disagree about a bucket, the most-used reading wins, - // whichever endpoint reported it. expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); expect(byId.get("3p-weekly")?.usedPercent).toBe(100); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 55ef3579942fb..75333ae8262e7 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -223,8 +223,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ), ).pipe(Effect.flatMap(decodeTokenResponse)); accessToken = refreshed.access_token; - // Each machine's sign-in has its own refresh token, so the Google - // account id is what lets clients merge one account across environments. + // Use Google account IDs to merge accounts across environments; refresh tokens differ per machine. account = yield* request( HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), @@ -246,9 +245,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } - // Endpoints can report different quota groups (the daily endpoint has - // returned the Claude/GPT group without Gemini), so merge every endpoint's - // windows. The most-used reading of a bucket wins, as in modelWindows. + // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, parse: (response: unknown) => Effect.Effect, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 2f5fe5dbd7829..eda97c1c0e9b4 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -7,6 +7,7 @@ import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/Antigrav import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; +import { FxDriver, type FxDriverEnv } from "@t3tools/provider-fx/server"; import { GrokDriver, type GrokDriverEnv } from "@t3tools/provider-grok/server"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; @@ -23,6 +24,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | FxDriverEnv | GrokDriverEnv | OpenCodeDriverEnv | PiDriverEnv @@ -34,6 +36,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray | ProviderUsageReaderEnv; -/** - * Aggregation keeps the first copy of a duplicate record, so the order is part - * of the result. - */ +/** Order matters: aggregation keeps the first duplicate record. */ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< AnyProviderDriver > = [ diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index fbc3efa2e5d4c..35973378edada 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -81,9 +81,7 @@ class ClaudeResetCreditError extends Schema.TaggedError( const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); -/** - * An unanswered or unconfirmed claim retries with the same request id. - */ +/** Retry unanswered or unconfirmed claims with the same request ID. */ export const isSettledClaudeResetCreditFailure = (error: unknown) => isClaudeResetCreditError(error) && error.reason !== "requestFailed" && @@ -196,10 +194,7 @@ const CLAIM_OUTCOMES = { ineligible: "noCredit", } as const satisfies Record; -/** - * `requestId` is the idempotency key: a retry with the same id is the same - * claim. - */ +/** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; readonly accountConfigPath: string; @@ -255,8 +250,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (body.result === "cooldown") { return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); } - // Claude could not say whether the claim landed, so, like the CLI, keep the - // request id and let the retry ask about the same claim. + // Unknown outcomes retain the request ID so retries address the same claim. if (body.result === "unavailable") { return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); } diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index 7fc0a63a605da..f20d8b0543636 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -16,8 +16,6 @@ describe("claudeUsageResponseToLimits", () => { five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, seven_day_opus: { utilization: 3, resets_at: null }, - // Newer CLIs add this on top of the typed keys; the pinned SDK - // typings do not know it yet. ...({ model_scoped: [ { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index e41dac250aceb..d0acf43bb4968 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -26,13 +26,7 @@ const WINDOWS: Readonly< seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, }; -/** - * The streamed event names the overage-included bucket by type - * (`seven_day_overage_included`), while `get_usage` names it by the model's - * `display_name`. Which model that is changes over time, so the probe records - * the name it saw and the event mapper reuses it; the mid-turn update then - * lands on the row the probe drew instead of opening a second one. - */ +/** Reuse probed display names for streamed bucket types so mid-turn updates target the same row. */ const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; // The SDK does not yet type the spend budget returned by `get_usage`. @@ -157,11 +151,7 @@ function makeWindow( }; } -/** - * Utilization is a 0–1 fraction on the streamed event. An overage-included - * event before any probe has named the bucket is dropped: guessing a name - * would draw a row the next probe cannot reconcile. - */ +/** Stream utilization is 0-1. Drop unnamed overage buckets until probed to avoid unreconcilable rows. */ export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames, @@ -181,9 +171,7 @@ export function claudeRateLimitEventToUpdate( return undefined; } -/** - * Percentages on the `get_usage` response are already 0–100. - */ +/** get_usage percentages are already 0-100. */ export function claudeUsageResponseToLimits(input: { readonly response: Pick; readonly checkedAt: string; @@ -204,8 +192,7 @@ export function claudeUsageResponseToLimits(input: { if (!window || typeof window.utilization !== "number") continue; windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); } - // The CLI filters `model_scoped` to the overage-included allowlist, which - // today holds one model; the first entry is the one the event refers to. + // The CLI's overage allowlist currently has one model; events refer to its first entry. let overageIncluded: string | undefined; for (const entry of readModelScoped(response.rate_limits)) { if (typeof entry.utilization !== "number") continue; diff --git a/apps/server/src/provider/codexModelCatalog.ts b/apps/server/src/provider/codexModelCatalog.ts index 037dad8b0d9bc..a459b1af81de0 100644 --- a/apps/server/src/provider/codexModelCatalog.ts +++ b/apps/server/src/provider/codexModelCatalog.ts @@ -14,10 +14,7 @@ const CodexModelAdapter = Schema.Struct({ }); const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); -/** - * Codex lists only the models its own build knows, so a model released after - * the installed CLI never shows up. - */ +/** Codex catalogs omit models released after the installed CLI. */ function codexUpdateRequiredModels( catalog: ModelCatalog.ProviderCatalog | undefined, draft: ServerProviderDraft, diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 51970a3a54726..9c863f54c0867 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -5,7 +5,6 @@ import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, codexRateLimitsToUpdate, - codexUsageLimitMessage, mergeCodexRateLimits, } from "./codexUsageLimits.ts"; @@ -129,6 +128,29 @@ describe("codexRateLimitsToLimits", () => { }); describe("codexRateLimitsToUpdate", () => { + it("does not republish cached windows over a newer account probe", () => { + const cached = { + primary: { usedPercent: 0, resetsAt: 1_784_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 20, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, + }; + expect(codexRateLimitsToUpdate(cached)?.windows).toHaveLength(2); + expect(codexRateLimitsToUpdate(structuredClone(cached), cached)).toBeUndefined(); + + const changed = { ...cached, secondary: { ...cached.secondary, usedPercent: 21 } }; + expect(codexRateLimitsToUpdate(changed, cached)?.windows.map((window) => window.id)).toEqual([ + "secondary", + ]); + expect(codexRateLimitsToUpdate({ primary: cached.primary }, cached)).toBeUndefined(); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, resetsAt: 1_784_001_000 } }, cached) + ?.windows, + ).toHaveLength(1); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, usedPercent: 76 } }, cached) + ?.windows[0]?.usedPercent, + ).toBe(76); + }); + it("carries only the windows the notification names", () => { expect( codexRateLimitsToUpdate({ @@ -188,50 +210,6 @@ describe("codexRateLimitsFailureMessage", () => { }); }); -describe("codexUsageLimitMessage", () => { - const at = "2026-01-01T00:00:00.000Z"; - const atSeconds = Date.parse(at) / 1000; - - it("names the exhausted window and the workspace's missing credits", () => { - expect( - codexUsageLimitMessage( - { - limitId: "codex", - rateLimitReachedType: "workspace_owner_credits_depleted", - primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, - secondary: { - usedPercent: 100, - resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, - windowDurationMins: 10_080, - }, - }, - at, - ), - ).toBe( - "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", - ); - }); - - it("points a reached spend cap at the workspace owner", () => { - expect( - codexUsageLimitMessage( - { - limitId: "codex", - rateLimitReachedType: "workspace_member_usage_limit_reached", - primary: { - usedPercent: 100, - resetsAt: atSeconds + 3 * 3_600 + 20 * 60, - windowDurationMins: 300, - }, - }, - at, - ), - ).toBe( - "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", - ); - }); -}); - describe("mergeCodexRateLimits", () => { it("updates monthly credit usage, retains it when omitted, and clears an explicit null", () => { const snapshot = { diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 0baae05dd816d..d4f9c621119df 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -56,16 +56,11 @@ function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; } -/** - * `primary` / `secondary` are positions, not durations. Codex usually sends - * `windowDurationMins`; when it does not, paid plans expose the 5-hour and - * weekly pair and Free/Go expose one monthly allowance. - */ +/** primary/secondary are positions; without windowDurationMins, paid plans use 5-hour/weekly windows and Free/Go use monthly. */ function codexRateLimitsToWindows( snapshot: CodexRateLimitSnapshot, ): ReadonlyArray { - // Show the main allowance only. Model-specific notifications (such as Spark) - // must not replace its primary/secondary rows. Older CLIs omit the limit id. + // Keep model-specific limits out of main allowance rows; older CLIs omit limit IDs. if (snapshot.limitId && snapshot.limitId !== "codex") return []; const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; const positions = [ @@ -140,8 +135,20 @@ export function codexRateLimitsToLimits(input: { export function codexRateLimitsToUpdate( snapshot: CodexRateLimitSnapshot, + previous?: CodexRateLimitSnapshot, ): ProviderUsageLimitsUpdate | undefined { - const windows = codexRateLimitsToWindows(snapshot); + const previousWindows = previous ? codexRateLimitsToWindows(previous) : []; + // Token ticks repeat session-cached limits that may predate an account probe or reset. + const windows = codexRateLimitsToWindows(snapshot).filter((window) => { + const existing = previousWindows.find((candidate) => candidate.id === window.id); + return ( + !existing || + existing.kind !== window.kind || + existing.usedPercent !== window.usedPercent || + existing.resetsAt !== window.resetsAt || + existing.windowDurationMins !== window.windowDurationMins + ); + }); return windows.length > 0 ? { windows } : undefined; } @@ -158,18 +165,12 @@ export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerE } } -/** - * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a - * field the update omits keeps the value observed earlier in the session, so a - * later notification that only names the limit it reached must not drop the - * windows an earlier one carried. - */ +/** Rate-limit notifications are partial: omitted fields preserve earlier session values. */ export function mergeCodexRateLimits( previous: CodexRateLimitSnapshot | undefined, update: CodexRateLimitSnapshot, ): CodexRateLimitSnapshot | undefined { - // Model-specific snapshots (such as Spark) describe a different allowance - // and must not overwrite the main one, the same rule the usage rows apply. + // Model-specific snapshots must not overwrite the main allowance. if (update.limitId && update.limitId !== "codex") return previous; if (!previous) return update; return { @@ -185,47 +186,6 @@ export function mergeCodexRateLimits( }; } -function formatCodexUsageLimitWait(waitMs: number): string { - const totalMinutes = Math.ceil(waitMs / 60_000); - const days = Math.floor(totalMinutes / (24 * 60)); - const hours = Math.floor((totalMinutes % (24 * 60)) / 60); - const minutes = totalMinutes % 60; - if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; - if (hours === 0) return `${totalMinutes}m`; - return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; -} - -function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { - switch (rateLimitReachedType) { - case "workspace_owner_credits_depleted": - case "workspace_member_credits_depleted": - return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets."; - case "workspace_owner_usage_limit_reached": - case "workspace_member_usage_limit_reached": - return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; - default: - return " Send the message again once the limit resets."; - } -} - -export function codexUsageLimitMessage( - snapshot: CodexRateLimitSnapshot | undefined, - atIso: string, -): string { - const atMs = Date.parse(atIso); - const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; - let reset = ""; - let latestResetMs = Number.NEGATIVE_INFINITY; - for (const window of windows) { - if (window.usedPercent < 100 || !window.resetsAt) continue; - const resetMs = Date.parse(window.resetsAt); - if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; - latestResetMs = resetMs; - reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; - } - return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; -} - /** All exhausted windows must reset before a continuation can run. */ export function codexUsageLimitResetAt( snapshot: CodexRateLimitSnapshot | undefined, diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts new file mode 100644 index 0000000000000..569493906d385 --- /dev/null +++ b/apps/server/src/provider/documentTextWorker.ts @@ -0,0 +1,648 @@ +// @effect-diagnostics nodeBuiltinImport:off -- isolated CLI worker; no server runtime is loaded here. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { characterEntities } from "character-entities"; +import { SaxesParser } from "saxes"; +import * as Yauzl from "yauzl"; + +const MAX_INPUT = 16 * 1024 * 1024; +const MAX_EXPANDED = 32 * 1024 * 1024; +const MAX_OUTPUT = 1_000_000; +const CELL_START = Symbol("cell-start"); +const CELL_END = Symbol("cell-end"); +type TextPart = string | typeof CELL_START | typeof CELL_END | { fallback: string }; + +const TRUNCATED = "\n\n[Extracted text truncated at document extraction limits.]"; + +class Truncation extends Error {} + +interface XmlNode { + name: string; + attrs: Record; + children: Array; + spaceCount?: number; +} + +function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { + const root: XmlNode = { name: "root", attrs: {}, children: [] }; + const stack = [root]; + let nodes = 0; + const parser = new SaxesParser(); + if (htmlEntities) Object.assign(parser.ENTITIES, characterEntities); + parser.on("opentag", (tag) => { + if (++nodes > 200_000 || stack.length > 128) throw new Truncation("XML structure limit"); + const node: XmlNode = { + name: tag.name.split(":").at(-1)!, + attrs: Object.fromEntries( + Object.entries(tag.attributes).flatMap(([key, value]) => [ + [key, String(value)], + [key.split(":").at(-1)!, String(value)], + ]), + ), + children: [], + }; + if (odfSpaces && node.name === "s") node.spaceCount = repeatCount(node.attrs.c); + stack.at(-1)!.children.push(node); + stack.push(node); + }); + parser.on("closetag", () => { + stack.pop(); + }); + const text = (value: string) => { + stack.at(-1)!.children.push(value); + }; + parser.on("text", text); + parser.on("cdata", text); + parser.write(source).close(); + return root; +} + +const children = (node: XmlNode) => + node.children.filter((child): child is XmlNode => typeof child !== "string"); +function* all(node: XmlNode, name: string | readonly string[]): Generator { + for (const child of children(node)) { + if (typeof name === "string" ? child.name === name : name.includes(child.name)) yield child; + else yield* all(child, name); + } +} +function* text(node: XmlNode): Generator { + if (["script", "style", "binary-data", "rPh"].includes(node.name)) return; + if (["tab", "cell"].includes(node.name)) yield "\t"; + else if (["br", "line-break", "cr"].includes(node.name)) yield "\n"; + else if (node.spaceCount !== undefined) { + yield " ".repeat(Math.min(MAX_OUTPUT, node.spaceCount)); + if (node.spaceCount > MAX_OUTPUT) throw new Truncation("Space repeat limit"); + } else + for (const child of node.children) { + if (typeof child === "string") yield child; + else yield* text(child); + } +} +function* paragraphs(node: XmlNode): Generator { + if (["script", "style", "binary-data"].includes(node.name)) return; + for (const child of node.children) { + if (typeof child === "string") yield child; + else if (["br", "line-break", "cr"].includes(child.name)) yield "\n"; + else if (["tbl", "table"].includes(child.name)) yield* table(child); + else if (["p", "h"].includes(child.name) || /^h[1-6]$/.test(child.name)) { + const style = all(child, "pStyle").next().value?.attrs.val ?? ""; + const level = + child.name === "h" + ? Number(child.attrs["outline-level"]) || 1 + : /^h[1-6]$/.test(child.name) + ? Number(child.name[1]) + : Number(style.match(/^Heading([1-6])$/i)?.[1]) || 0; + if (level) yield `${"#".repeat(Math.min(6, level))} `; + yield* text(child); + yield "\n"; + } else { + yield* paragraphs(child); + if (["li", "div", "section"].includes(child.name)) yield "\n"; + } + } +} +function* inCell(parts: Iterable): Generator { + yield CELL_START; + yield* parts; + yield CELL_END; +} +function repeatCount(value: string | undefined): number { + if (value === undefined) return 1; + if (!/^[0-9]+$/.test(value) || Number(value) < 1) throw new Error("Invalid repeat count"); + return Number(value); +} +function odfValue(cell: XmlNode): string { + const type = cell.attrs["office:value-type"] ?? cell.attrs["value-type"]; + const attr = (name: string) => cell.attrs[`office:${name}`] ?? cell.attrs[name] ?? ""; + switch (type) { + case "void": + return ""; + case "string": + return attr("string-value"); + case "boolean": + return attr("boolean-value"); + case "date": + return attr("date-value"); + case "time": + return attr("time-value"); + case "currency": + return attr("value").trim() ? `${attr("value")} ${attr("currency")}`.trim() : ""; + case "percentage": + return attr("value").trim() ? `${attr("value")} [ODF value-type=percentage]` : ""; + default: + return attr("value"); + } +} +function hasCellText(cell: XmlNode): boolean { + if (odfValue(cell).trim()) return true; + // Space markers can have enormous repeat counts; emptiness never requires expanding them. + if ( + ["script", "style", "binary-data", "rPh"].includes(cell.name) || + cell.spaceCount !== undefined + ) + return false; + return cell.children.some((child) => + typeof child === "string" ? !!child.trim() : hasCellText(child), + ); +} +function* table(node: XmlNode): Generator { + let rowCount = 0; + for (const row of all(node, ["tr", "table-row"])) { + const cells = children(row).filter((cell) => + ["tc", "table-cell", "covered-table-cell", "td", "th"].includes(cell.name), + ); + // ODS writers pad sheets with repeated empty rows and trailing cells. + while (cells.length && !hasCellText(cells.at(-1)!)) cells.pop(); + if (!cells.length) continue; + const repeat = repeatCount(row.attrs["number-rows-repeated"]); + for (let index = 0; index < repeat; index++) { + if (++rowCount > 10_000) throw new Truncation("Row limit"); + let column = 0; + for (const cell of cells) { + const count = repeatCount(cell.attrs["number-columns-repeated"]); + for (let i = 0; i < count; i++) { + if (column >= 512) throw new Truncation("Column limit"); + if (column++) yield "\t"; + yield CELL_START; + yield* paragraphs(cell); + yield { fallback: odfValue(cell) }; + yield CELL_END; + } + } + yield "\n"; + } + } +} + +async function archive(path: string): Promise> { + const zip = await new Promise((resolve, reject) => + Yauzl.open( + path, + { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, + (error, result) => (error || !result ? reject(error) : resolve(result)), + ), + ); + const files = new Map(); + let expanded = 0; + let count = 0; + try { + await new Promise((resolve, reject) => { + zip.on("error", reject); + zip.on("end", resolve); + zip.on("entry", (entry: Yauzl.Entry) => { + if (++count > 2048 || zip.entryCount > 2048) { + reject(new Truncation("Archive limit")); + return; + } + if (entry.generalPurposeBitFlag & 1) { + reject(new Error("Encrypted archive")); + return; + } + if (!/\.(xml|rels|xhtml|html|htm|opf|svg)$/i.test(entry.fileName)) { + zip.readEntry(); + return; + } + if ( + entry.uncompressedSize > MAX_EXPANDED || + entry.uncompressedSize > Math.max(1024, entry.compressedSize * 200) + ) { + reject(new Truncation("Archive limit")); + return; + } + expanded += entry.uncompressedSize; + if (expanded > MAX_EXPANDED) { + reject(new Truncation("Expanded byte limit")); + return; + } + zip.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject(error); + return; + } + const buffers: Buffer[] = []; + let size = 0; + stream.on("error", reject); + stream.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > entry.uncompressedSize || size > MAX_EXPANDED) { + stream.destroy(new Truncation("Entry byte limit")); + return; + } + buffers.push(chunk); + }); + stream.on("end", () => { + const data = Buffer.concat(buffers); + // XML's BOM and initial '<' bytes distinguish both UTF-16 byte orders. + const encoding = + (data[0] === 0xff && data[1] === 0xfe) || (data[0] === 0x3c && data[1] === 0) + ? "utf-16le" + : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c) + ? "utf-16be" + : "utf-8"; + files.set(entry.fileName, new TextDecoder(encoding).decode(data)); + zip.readEntry(); + }); + }); + }); + zip.readEntry(); + }); + } finally { + zip.close(); + } + return files; +} + +function* notebook(source: string): Generator { + const book: unknown = JSON.parse(source); + if (!book || typeof book !== "object" || !("cells" in book) || !Array.isArray(book.cells)) + throw new Error("Invalid notebook"); + function* lines(value: unknown): Generator { + if (typeof value === "string") yield value; + else if (Array.isArray(value)) + for (const line of value) { + if (typeof line === "string") yield line; + } + } + for (const [index, cell] of book.cells.entries()) { + if (index >= 10_000) throw new Truncation("Notebook cell limit"); + if (!cell || typeof cell !== "object") continue; + const kind = "cell_type" in cell && cell.cell_type === "code" ? "code" : "markdown"; + yield `## Cell ${index + 1} (${kind})\n`; + if ("source" in cell) yield* lines(cell.source); + if ("outputs" in cell && Array.isArray(cell.outputs)) { + yield "\nOutput:\n"; + for (const output of cell.outputs) { + if (!output || typeof output !== "object") continue; + if ("text" in output) yield* lines(output.text); + else if ( + "data" in output && + output.data && + typeof output.data === "object" && + "text/plain" in output.data + ) + yield* lines(output.data["text/plain"]); + else if ("output_type" in output && output.output_type === "error") { + if ("ename" in output) yield* lines(output.ename); + if ("evalue" in output) { + yield ": "; + yield* lines(output.evalue); + } + yield "\n"; + if ("traceback" in output) + for (const line of lines(output.traceback)) { + yield NodeUtil.stripVTControlCharacters(line); + yield "\n"; + } + } + yield "\n"; + } + } + yield "\n\n"; + } +} + +function* rtf(source: string): Generator { + if (!source.startsWith("{\\rtf")) throw new Error("Invalid RTF document"); + let state = { skip: false, uc: 1, decoder: new TextDecoder("windows-1252") }; + const stack: Array = []; + const symbols = new Map([ + ["endash", "–"], + ["emdash", characterEntities.mdash], + ["enspace", "\u2002"], + ["emspace", "\u2003"], + ["qmspace", "\u2005"], + ["lquote", "‘"], + ["rquote", "’"], + ["ldblquote", "“"], + ["rdblquote", "”"], + ["bullet", "•"], + ["~", "\u00a0"], + ["_", "\u2011"], + ["-", ""], + ]); + let fallback = 0; + for (let i = 0; i < source.length; i++) { + const char = source[i]!; + if (char === "{" || char === "}") fallback = 0; + if (char === "{") { + stack.push({ ...state }); + if (stack.length > 128) throw new Truncation("RTF depth limit"); + } else if (char === "}") state = stack.pop() ?? state; + else if (char === "\\") { + const rest = source.slice(i + 1, i + 80); + const hex = /^'([0-9a-f]{2})/i.exec(rest); + const word = /^([a-z]+)(-?\d+)? ?/i.exec(rest); + if (hex) { + if (fallback) fallback--; + else if (!state.skip) + yield state.decoder.decode(Uint8Array.of(Number.parseInt(hex[1]!, 16))); + i += 3; + } else if (word) { + i += word[0].length; + const control = word[1]!; + const value = Number(word[2]); + if (control === "bin") { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("Invalid RTF binary length"); + i += value; + } + if (fallback) { + fallback--; + continue; + } + if ( + [ + "fonttbl", + "colortbl", + "stylesheet", + "info", + "pict", + "object", + "fldinst", + "header", + "footer", + ].includes(control) + ) + state.skip = true; + if (control === "ansicpg") { + // Per-byte decoding only supports single-byte Windows code pages. + if (value !== 874 && (value < 1250 || value > 1258)) + throw new Error("Unsupported RTF code page"); + state.decoder = new TextDecoder(`windows-${value}`, { fatal: true }); + } else if (control === "uc") { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("Invalid RTF fallback count"); + state.uc = value; + } else if (control === "u" && !state.skip) { + yield String.fromCharCode(value < 0 ? value + 65536 : value); + fallback = state.uc; + } else if (!state.skip && ["par", "line", "row"].includes(control)) yield "\n"; + else if (!state.skip && ["tab", "cell"].includes(control)) yield "\t"; + else if (!state.skip && symbols.has(control)) yield symbols.get(control)!; + } else { + i++; + if (fallback) fallback--; + else if (rest[0] === "*") state.skip = true; + else if (!state.skip && ["\\", "{", "}"].includes(rest[0]!)) yield rest[0]!; + else if (!state.skip && symbols.has(rest[0]!)) yield symbols.get(rest[0]!)!; + } + } else if (!state.skip && char !== "\n" && char !== "\r") { + if (fallback) fallback--; + else + yield char.charCodeAt(0) < 128 + ? char + : state.decoder.decode(Uint8Array.of(char.charCodeAt(0))); + } + } +} + +async function* document(path: string, extension: string): AsyncGenerator { + if ((await NodeFSP.stat(path)).size > MAX_INPUT) throw new Truncation("Input byte limit"); + if (extension === ".ipynb") yield* notebook(await NodeFSP.readFile(path, "utf8")); + else if (extension === ".rtf") yield* rtf((await NodeFSP.readFile(path)).toString("latin1")); + else { + const files = await archive(path); + const notes: string[] = []; + const skip = (name: string) => + notes.push(`\n[Skipped document part: ${name} (missing or invalid XML).]\n`); + const read = (name: string): XmlNode => { + try { + const data = files.get(name); + if (!data) throw new Error("Missing document part"); + return xml(data, [".odt", ".ods", ".odp"].includes(extension), extension === ".epub"); + } catch (error) { + if (error instanceof Truncation) throw error; + skip(name); + return { name: "root", attrs: {}, children: [] }; + } + }; + const relations = (name: string) => + new Map( + [...all(read(name), "Relationship")] + .filter((rel) => rel.attrs.TargetMode !== "External") + .map((rel) => [rel.attrs.Id, rel.attrs.Target]), + ); + if (extension === ".docx") yield* paragraphs(read("word/document.xml")); + else if ([".odt", ".ods", ".odp"].includes(extension)) { + const content = read("content.xml"); + if (extension === ".odt") yield* paragraphs(content); + else { + let index = 0; + for (const part of all(content, extension === ".ods" ? "table" : "page")) { + yield extension === ".ods" + ? `\n\n## Sheet: ${part.attrs.name ?? "Untitled"}\n` + : `\n\n## Slide ${++index}\n`; + if (extension === ".ods") yield* table(part); + else yield* paragraphs(part); + } + } + } else if (extension === ".xlsx") { + const workbook = read("xl/workbook.xml"); + const dateSystem = ["1", "true"].includes( + all(workbook, "workbookPr").next().value?.attrs.date1904 ?? "", + ) + ? "1904" + : "1900"; + const styles = files.has("xl/styles.xml") ? read("xl/styles.xml") : undefined; + const customFormats = new Map( + styles + ? [...all(styles, "numFmt")].map((node) => [node.attrs.numFmtId, node.attrs.formatCode]) + : [], + ); + const cellFormats = styles ? all(styles, "cellXfs").next().value : undefined; + const formats = cellFormats ? children(cellFormats) : []; + const strings = files.has("xl/sharedStrings.xml") + ? [...all(read("xl/sharedStrings.xml"), "si")] + : []; + const populatedStrings = strings.map(hasCellText); + const rels = relations("xl/_rels/workbook.xml.rels"); + for (const sheet of all(workbook, "sheet")) { + yield `\n## Sheet: ${sheet.attrs.name}\n`; + const target = rels.get(sheet.attrs["r:id"] ?? sheet.attrs.id); + if (!target) { + skip(`sheet relationship ${sheet.attrs.name}`); + continue; + } + const name = target.startsWith("/") + ? target.slice(1) + : NodePath.posix.normalize(`xl/${target}`); + const populated = (cell: XmlNode) => { + const value = all(cell, "v").next().value; + if (cell.attrs.t === "s") { + const raw = value?.children.find((child) => typeof child === "string")?.trim(); + return !!raw && !!populatedStrings[Number(raw)]; + } + if (cell.attrs.t === "inlineStr") return hasCellText(cell); + if (value && hasCellText(value)) return true; + for (const formula of all(cell, "f")) if (hasCellText(formula)) return true; + return false; + }; + let rowCount = 0; + for (const row of all(read(name), "row")) { + const cells: Array = []; + let nextColumn = 0; + let columnsTruncated = false; + for (const cell of children(row).filter((node) => node.name === "c")) { + const letters = cell.attrs.r?.match(/^[A-Z]+/)?.[0]; + const column = letters + ? [...letters].reduce((n, char) => n * 26 + char.charCodeAt(0) - 64, 0) - 1 + : nextColumn; + nextColumn = column + 1; + if (!populated(cell)) continue; + if (column >= 512) { + columnsTruncated = true; + continue; + } + cells[column] = cell; + } + if (!cells.length && !columnsTruncated) continue; + if (++rowCount > 10_000) throw new Truncation("Row limit"); + for (const [column, cell] of cells.entries()) { + if (column) yield "\t"; + if (!cell) continue; + const value = all(cell, "v").next().value; + const raw = value?.children.find((child) => typeof child === "string") ?? ""; + if (cell.attrs.t === "s") { + const shared = strings[Number(raw)]; + if (shared) yield* inCell(text(shared)); + } else if (cell.attrs.t === "inlineStr") yield* inCell(text(cell)); + else { + if (value && hasCellText(value)) yield* inCell(text(value)); + else for (const formula of all(cell, "f")) yield* inCell(text(formula)); + const formatId = formats[Number(cell.attrs.s ?? 0)]?.attrs.numFmtId; + const format = formatId ? customFormats.get(formatId) : undefined; + if ( + (cell.attrs.t === undefined || cell.attrs.t === "n") && + raw.trim() && + formatId && + formatId !== "0" + ) + yield ` [Excel numFmtId=${formatId}${format ? `; format=${format}` : ""}; date system ${dateSystem}]`; + } + } + yield "\n"; + if (columnsTruncated) throw new Truncation("Column limit"); + } + } + } else if (extension === ".pptx") { + const rels = relations("ppt/_rels/presentation.xml.rels"); + let index = 0; + for (const slide of all(read("ppt/presentation.xml"), "sldId")) { + yield `\n\n## Slide ${++index}\n`; + const target = rels.get(slide.attrs["r:id"] ?? slide.attrs.id); + if (!target) { + skip(`slide relationship ${index}`); + continue; + } + const name = target.startsWith("/") + ? target.slice(1) + : NodePath.posix.normalize(`ppt/${target}`); + yield* paragraphs(read(name)); + const slideRels = NodePath.posix.join( + NodePath.posix.dirname(name), + "_rels", + `${NodePath.posix.basename(name)}.rels`, + ); + if (!files.has(slideRels)) continue; + for (const rel of all(read(slideRels), "Relationship")) { + const target = rel.attrs.Target; + if ( + !rel.attrs.Type?.endsWith("/notesSlide") || + rel.attrs.TargetMode === "External" || + !target + ) + continue; + yield "Notes:\n"; + yield* paragraphs( + read( + target.startsWith("/") + ? target.slice(1) + : NodePath.posix.join(NodePath.posix.dirname(name), target), + ), + ); + } + } + } else if (extension === ".epub") { + const rootfile = all(read("META-INF/container.xml"), "rootfile").next().value?.attrs[ + "full-path" + ]; + if (!rootfile) { + if (!notes.length) skip("EPUB package"); + yield* notes; + return; + } + const packageXml = read(rootfile); + const items = new Map( + [...all(packageXml, "item")].map((item) => [item.attrs.id, item.attrs.href]), + ); + let index = 0; + for (const item of all(packageXml, "itemref")) { + yield `\n\n## Chapter ${++index}\n`; + const href = items.get(item.attrs.idref); + if (!href) { + skip(`chapter ${index}`); + continue; + } + let decoded: string; + try { + decoded = decodeURIComponent(href.split("#")[0]!); + } catch { + skip(`chapter ${index}`); + continue; + } + const name = NodePath.posix.normalize( + NodePath.posix.join(NodePath.posix.dirname(rootfile), decoded), + ); + yield* paragraphs(read(name)); + } + } else throw new Error("Unsupported document"); + yield* notes; + } +} + +export async function extractDocumentText(path: string, extension: string): Promise { + let result = ""; + let remaining = MAX_OUTPUT; + let cellDepth = 0; + let started = false; + let space = false; + let truncated = false; + try { + // Charge raw text before normalization so repeated whitespace also exhausts the budget. + for await (const part of document(path, extension)) { + if (part === CELL_START) { + if (cellDepth++ === 0) { + started = false; + space = false; + } else if (started) space = true; + continue; + } + if (part === CELL_END) { + cellDepth--; + if (cellDepth > 0 && started) space = true; + continue; + } + if (typeof part !== "string" && started) continue; + const raw = typeof part === "string" ? part : part.fallback; + let value = raw.slice(0, remaining).replaceAll("\0", ""); + remaining -= Math.min(raw.length, remaining); + if (cellDepth) { + value = value.replaceAll(/\s+/g, " "); + const trimmed = value.trim(); + if (trimmed) { + result += (started && (space || value.startsWith(" ")) ? " " : "") + trimmed; + started = true; + } + space = value.endsWith(" ") || (!value && space); + } else result += value; + if (remaining === 0) throw new Truncation("Output budget"); + } + } catch (error) { + if (!(error instanceof Truncation)) throw error; + truncated = true; + } + result = result.replaceAll(/\n(?:[ \t]*\n){2,}/g, "\n\n").trim(); + if (!result && !truncated) throw new Error("No text extracted"); + return result + (truncated ? TRUNCATED : ""); +} diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 59e82fe5736f2..c34f0a6f98744 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-21T06:20:00Z", + "updatedAt": "2026-10-28T08:10:39Z", "compatibility": [ { "driver": "codex", @@ -104,6 +104,15 @@ { "range": ">=1.4.2", "status": "supported" }, { "range": "<1.4.2", "status": "unknown" } ] + }, + { + "driver": "fx", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=0.0.13", + "ranges": [ + { "range": ">=0.0.13", "status": "supported" }, + { "range": "<0.0.13", "status": "unsupported" } + ] } ], "currentModels": { @@ -150,7 +159,7 @@ }, "claudeAgent": { "defaults": { - "chat": "claude-fable-5-1" + "chat": "claude-opus-5-5" }, "profiles": { "fable-5": { @@ -782,7 +791,7 @@ "name": "Claude Haiku 5.5", "aliases": ["haiku", "haiku-5.5", "claude-haiku-5.5"], "status": "current", - "badge": "new", + "releasedAt": "2026-10-07", "profile": "haiku-5-5", "adapter": { "claudeCode": { "minVersion": "2.1.293" } } }, diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index dbb7a2bfc393d..e3d4f672016c0 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -52,8 +52,7 @@ const provider: ServerProvider = { describe("provider compatibility", () => { it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => { - // Every installed build fetches main's manifest, and 0.0.45 and older run the - // V1 orchestrator, which cannot drive OpenCode 2. + // Older builds fetch main's manifest; their V1 orchestrator cannot drive OpenCode 2. const opencode = ProviderDriverKind.make("opencode"); for (const [version, expected] of [ ["1.14.19", "supported"], diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index 038899a116d08..69322915f458a 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -65,9 +65,7 @@ export function resolveProviderCompatibility( ); if (!policy) return undefined; const unprefixed = version?.replace(/^v/, ""); - // Cursor appends a build hash to its date; Google's ACP runtime uses a release prefix; - // Muse appends a release revision. Strip only these driver-specific forms, keeping - // semver prereleases unknown. + // Strip Cursor build hashes, Google ACP release prefixes and Muse revisions; leave semver prereleases unknown. const stable = driver === "cursor" ? unprefixed?.replace(/^(\d{4}\.\d{2}\.\d{2})-[a-f0-9]+$/, "$1") diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 43808ff37df87..75c592b7811a1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -39,10 +39,7 @@ const NATIVE_CLI_INSTANCE_ID = ProviderInstanceId.make("nativeCli"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -// Pin a non-win32 platform so `resolveSpawnCommand` is a no-op and the raw -// `{ command, args }` assertions below hold deterministically on any host -// (including Windows). Windows-specific resolution is covered by the dedicated -// win32 case at the end of this suite. +// Pin Linux so raw command/args assertions bypass Windows shim resolution. const layerNonWindowsPlatform = Layer.succeed(HostProcess.Platform, "linux"); function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 43d8b1e3916ad..9dddf23579d8a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -43,8 +43,7 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; -// Every progress publish resends the provider list to each client, so the -// installer's output is sampled rather than forwarded line by line. +// Sample installer output because every progress update resends the provider list to all clients. const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); const UPDATE_PROGRESS_MAX_LENGTH = 200; // An installer may write for minutes without a line ending; only its tail matters. @@ -98,9 +97,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { - // On Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`), which a bare - // ChildProcess.spawn cannot launch (spawn npm ENOENT); resolveSpawnCommand finds the - // real `.cmd` and routes it through the shell. + // Windows batch shims require shell dispatch through resolveSpawnCommand. const resolved = yield* resolveSpawnCommand(input.command, input.args); const child = yield* input.spawner .spawn( @@ -292,7 +289,7 @@ function makeUpdateState(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const manifestService = yield* ModelManifest.ModelManifest; @@ -425,9 +422,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ).pipe(Effect.asVoid); yield* setRunningMessage("Checking for the latest version"); - // The cached capabilities chose the lock; re-derive ownership - // now so the command that runs matches the executable as it is - // at click time, not as it was at the last health refresh. + // Recheck ownership after acquiring the cached lock in case the executable changed. const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, @@ -522,8 +517,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { verified, instanceId, ); - // A missing version alone is not held against it, since Cursor's `about` probe can - // fail transiently on a healthy binary. + // Cursor about probes can fail transiently; missing versions alone do not imply failure. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some((verifiedProvider) => { diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 486b7fd41046f..85b76eb3fc63d 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -20,9 +20,7 @@ const mergeProviderModels = ( cachedModels: ReadonlyArray, ): ReadonlyArray => { const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - // The fallback snapshot is built from current settings and already carries - // every custom model, so cached custom rows that are not in it were removed - // while the cache was stale and must not come back. + // Settings already supply current custom models; cached rows could resurrect removed ones. return [ ...fallbackModels, ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), @@ -36,6 +34,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "grok", "opencode", "antigravity", + "fx", ]; const driverRank = (driver: string): number => { @@ -94,10 +93,7 @@ export const hydrateCachedProvider = (input: { : hydratedProvider; }; -/** - * Cache contents must still carry matching `instanceId` + `driver` identity - * before hydration. The filename alone is not trusted as a routing key. - */ +/** Require matching instanceId and driver before hydration; filenames alone cannot authorize routing. */ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( function* (input: { readonly cacheDir: string; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 45532eeb0550e..189c43b54f7f6 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -1,8 +1,4 @@ -/** - * Redeeming a reset credit is an account-level action: instances that share the - * directory holding a provider's login share the credit, so their redemptions - * must serialise on that directory, not the instance. - */ +/** Instances sharing a login directory share credits; serialize redemptions on that directory. */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -20,12 +16,7 @@ interface AccountRedemptionState { export class ResetCreditCoordinator extends Context.Service< ResetCreditCoordinator, { - /** - * The key is cleared when the provider reports an outcome, or when - * `isSettled` says a failure was a final answer (such as a cooldown). Any - * other failure (timeout included) keeps it so the next attempt is the same - * attempt. - */ + /** Reuse the idempotency key until an outcome or settled failure; timeouts keep it for retry. */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, @@ -35,13 +26,11 @@ export class ResetCreditCoordinator extends Context.Service< >()("t3/provider/resetCreditCoordinator") {} /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const statesRef = yield* Ref.make>(new Map()); - // Get-or-create through one Ref.modify so two first redemptions for the - // same account cannot each install their own lock. + // Create account locks atomically so concurrent first redemptions cannot use separate locks. const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { const existing = (yield* Ref.get(statesRef)).get(accountKey); if (existing) return existing; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 8e6284e3448dc..cae8ea2c9380d 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -23,9 +23,7 @@ rl.on("line", (line) => { } const { id, method } = message; if (openServerRequests.has(id)) { - // Real codex identifies the resolved request by its item id — the key the - // runtime correlates on — so the receipt exercises the same path that - // closes the approval card in the UI. + // Match real Codex's resolved item ID so the runtime closes the corresponding approval card. const request = openServerRequests.get(id); openServerRequests.delete(id); NodeFS.appendFileSync( diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index 6e5134363cbce..9cb1b5b7e425d 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -96,9 +96,7 @@ lines.on("line", (line) => { }) + "\n", ); }); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. +// Stay alive for control requests only while the parent lives; SDK abort does not await child exit. const keepAlive = setInterval(() => {}, 1_000); lines.on("close", () => { clearInterval(keepAlive); diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index b9b6764abca7f..81c167c038d87 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -1,16 +1,33 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; +import * as DocumentText from "./DocumentText.ts"; +import { extractDocumentText } from "./documentTextWorker.ts"; +import * as Cleanup from "../orchestration-v2/ResourceCleanupService.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as NodeZlib from "node:zlib"; import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts"; -const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe( - Layer.provideMerge(NodeServices.layer), -); +vi.mock("node:child_process", { spy: true }); + +const layer = Layer.merge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }), + DocumentText.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); const attachment = { type: "file" as const, id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt", @@ -19,6 +36,1063 @@ const attachment = { sizeBytes: 4, }; describe("question answer paths", () => { + it.effect.each([false, true])( + "keeps unrelated files available during extraction (failure: %s)", + (failure) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = NodePath.join(config.attachmentsDir, "blocked.rtf"); + const cached = NodePath.join(config.attachmentsDir, "cached.rtf"); + NodeFS.writeFileSync(path, failure ? "invalid document" : String.raw`{\rtf1 Available}`); + NodeFS.writeFileSync(cached + ".txt", "Cached text"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + yield* Effect.gen(function* () { + const documents = yield* DocumentText.DocumentText; + const extraction = yield* documents.ensure(path).pipe(Effect.forkChild); + yield* Deferred.await(started); + const duplicate = yield* documents.ensure(path).pipe(Effect.forkChild); + expect(yield* documents.ensure(path + ".png")).toBeUndefined(); + expect(yield* documents.ensure(path + ".pdf")).toBeUndefined(); + expect(yield* documents.ensure(cached)).toBe(cached + ".txt"); + expect(NodeFS.readFileSync(cached + ".txt", "utf8")).toBe("Cached text"); + yield* Deferred.succeed(release, undefined); + const expected = failure ? undefined : path + ".txt"; + expect(yield* Fiber.join(extraction)).toBe(expected); + expect(yield* Fiber.join(duplicate)).toBe(expected); + expect(yield* documents.ensure(path)).toBe(expected); + expect(reads).toBe(1); + if (failure) { + NodeFS.writeFileSync(path + ".txt", "Recovered text"); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Recovered text"); + } else expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Available"); + }).pipe( + Effect.provide(Layer.fresh(DocumentText.layer)), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (file) => + file === path + ? Effect.gen(function* () { + reads++; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return yield* fs.stat(file); + }) + : fs.stat(file), + }), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect.each(["spawn", "read"])("retries after a transient %s failure expires", (failure) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const { execFile } = await vi.importActual("node:child_process"); + const worker = vi.spyOn(NodeChildProcess, "execFile"); + worker.mockImplementationOnce((( + file: string, + args: readonly string[], + options: NodeChildProcess.ExecFileOptionsWithStringEncoding, + callback: ( + error: NodeChildProcess.ExecFileException | null, + stdout: string, + stderr: string, + ) => void, + ) => { + if (failure === "spawn") { + throw Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" }); + } + const preload = `import fs from 'node:fs/promises'; + import { syncBuiltinESMExports } from 'node:module'; + fs.readFile = async () => { throw Object.assign(new Error('read EAGAIN'), { code: 'EAGAIN' }); }; + syncBuiltinESMExports();`; + return execFile( + file, + ["--import", `data:text/javascript,${encodeURIComponent(preload)}`, ...args], + options, + callback, + ); + }) as typeof NodeChildProcess.execFile); + return worker; + }), + (worker) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, "retry.rtf"); + NodeFS.writeFileSync(path, String.raw`{\rtf1 Available}`); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(worker).toHaveBeenCalledTimes(1); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + yield* TestClock.adjust("1 second"); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Available"); + expect(worker).toHaveBeenCalledTimes(2); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(worker).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(layer)), + (worker) => Effect.sync(() => worker.mockRestore()), + ), + ); + + it.effect.each([ + ["ipynb", "{"], + ["rtf", "invalid document"], + ["rtf", String.raw`{\rtf1}`], + ["rtf", String.raw`{\rtf1\ansi\ansicpg99999 Unsupported}`], + ] as const)("caches unchanged document rejections: %s %s", ([extension, source]) => + Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(NodeChildProcess, "execFile")), + (worker) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, `invalid.${extension}`); + NodeFS.writeFileSync(path, source); + const documents = yield* DocumentText.DocumentText; + for (const seconds of [0, 1, 60]) { + yield* TestClock.adjust(`${seconds} seconds`); + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(worker).toHaveBeenCalledTimes(1); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + } + }).pipe(Effect.provide(layer)), + (worker) => Effect.sync(() => worker.mockRestore()), + ), + ); + + it.effect.each([ + [ + "text symbols", + String.raw`{\rtf1\ansi Pages 1\endash 5\emdash\lquote left\rquote\ldblquote right\rdblquote\bullet\b End\b0\unknown{\*\secret\bullet hidden}}`, + "Pages 1–5—‘left’“right”•End", + ], + [ + "space and hyphen symbols", + String.raw`{\rtf1\ansi A\enspace B\emspace C\qmspace D\~E\_F\-G}`, + "A\u2002B\u2003C\u2005D\u00a0E\u2011FG", + ], + [ + "text symbol fallback", + String.raw`{\rtf1\ansi\uc10\u945\endash\emdash\enspace\emspace\qmspace\lquote\rquote\ldblquote\rdblquote\bullet Z}`, + "αZ", + ], + ["hyphen symbol fallback", String.raw`{\rtf1\ansi\uc3\u945\~\_\-Z}`, "αZ"], + ["control word", String.raw`{\rtf1\ansi A\u9\tab X}`, "A\tX"], + ["group close", String.raw`{\rtf1\ansi{\u945}Z}`, "αZ"], + ["group open", String.raw`{\rtf1\ansi\u945{Z}}`, "αZ"], + ["hex byte", String.raw`{\rtf1\ansi\u945\'3fZ}`, "αZ"], + ["escaped symbol", String.raw`{\rtf1\ansi\u945\{Z}`, "αZ"], + ["destination symbol", String.raw`{\rtf1\ansi\u945\*Z}`, "αZ"], + ["multiple units", String.raw`{\rtf1\ansi\uc4\u945?\tab\'3f\~Z}`, "αZ"], + ["binary unit", String.raw`{\rtf1\ansi\u945\bin3 abcZ}`, "αZ"], + ["large fallback", String.raw`{\rtf1\ansi\uc20\u945????????????????????Z}`, "αZ"], + ["no fallback", String.raw`{\rtf1\ansi\uc0\u945Z}`, "αZ"], + ["scoped count", String.raw`{\rtf1\ansi{\uc2\u945??}\u946?Z}`, "αβZ"], + ] as const)("extracts RTF symbols and Unicode fallbacks: %s", ([name, source, expected]) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, `${name}.rtf`); + NodeFS.writeFileSync(path, source); + expect(yield* Effect.promise(() => extractDocumentText(path, ".rtf"))).toBe(expected); + expect(yield* (yield* DocumentText.DocumentText).ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(expected); + }).pipe(Effect.provide(layer)), + ); + + it.effect.each([false, true])("limits the child heap for standalone=%s", (standalone) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const { execFile } = await vi.importActual("node:child_process"); + const probe = vi.spyOn(NodeChildProcess, "execFile"); + probe.mockImplementation((( + file: string, + _args: readonly string[], + options: NodeChildProcess.ExecFileOptionsWithStringEncoding, + callback: ( + error: NodeChildProcess.ExecFileException | null, + stdout: string, + stderr: string, + ) => void, + ) => + execFile( + file, + [ + "-e", + 'process.stdout.write(String(require("node:v8").getHeapStatistics().heap_size_limit))', + ], + options, + callback, + )) as typeof NodeChildProcess.execFile); + return probe; + }), + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, "heap.rtf"); + NodeFS.writeFileSync(path, String.raw`{\rtf1 hello}`); + const extracted = yield* Effect.gen(function* () { + return yield* (yield* DocumentText.DocumentText).ensure(path); + }).pipe( + Effect.provide(Layer.fresh(DocumentText.layer)), + Effect.provideService(HostProcess.IsExecutable, standalone), + Effect.provideService(HostProcess.Environment, { + ...process.env, + NODE_OPTIONS: "--max-old-space-size=1024", + }), + ); + expect(extracted).toBe(path + ".txt"); + const heapLimit = Number(NodeFS.readFileSync(path + ".txt", "utf8")); + expect(heapLimit).toBeGreaterThan(192 * 1024 * 1024); + // Newer V8 versions reserve up to 192 MiB for the young generation as well. + expect(heapLimit).toBeLessThanOrEqual(384 * 1024 * 1024); + }).pipe(Effect.provide(layer)), + (probe) => Effect.sync(() => probe.mockRestore()), + ), + ); + + it.effect("extracts notebook cells and outputs once and keeps both readable paths", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const file = { + ...attachment, + id: attachment.id.replace(/txt$/, "ipynb"), + name: "survey.ipynb", + mimeType: "application/x-ipynb+json", + }; + const path = NodePath.join(config.attachmentsDir, `${file.id}.ipynb`); + const notebook = JSON.stringify({ + cells: [ + { cell_type: "markdown", source: ["# Survey\n", "Vessel: Larkspur"] }, + { + cell_type: "code", + source: ["print(43 * 7)"], + outputs: [ + { text: ["301\n"] }, + { data: { "text/plain": ["42"] } }, + { + output_type: "error", + ename: "ZeroDivisionError", + evalue: "division by zero", + traceback: [ + "\u001b[32mCell In[1], line 1\u001b[0m", + "\u001b[31m----> 1 1 / 0\u001b[0m", + "ZeroDivisionError: division by zero", + ], + }, + ], + }, + ], + }); + NodeFS.writeFileSync(path, notebook); + const answers = yield* appendUserInputAttachmentPaths({ + answers: { q: "Read this" }, + attachmentsDir: config.attachmentsDir, + attachmentsByQuestionId: { q: [file] }, + }); + expect(answers.q).toContain(`text extracted to: "${path}.txt"`); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("Vessel: Larkspur"); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("301"); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("42"); + const extracted = NodeFS.readFileSync(`${path}.txt`, "utf8"); + expect(extracted).toContain( + "ZeroDivisionError: division by zero\nCell In[1], line 1\n----> 1 1 / 0\nZeroDivisionError: division by zero", + ); + expect(extracted).not.toContain("\u001b"); + expect(NodeFS.readFileSync(path, "utf8")).toBe(notebook); + const before = NodeFS.statSync(path + ".txt").mtimeMs; + // A changed binary would fail parsing if the cached extraction were not reused. + NodeFS.writeFileSync(path, "invalid notebook"); + const documents = yield* DocumentText.DocumentText; + const results = yield* Effect.all([documents.ensure(path), documents.ensure(path)], { + concurrency: "unbounded", + }); + expect(results).toEqual([path + ".txt", path + ".txt"]); + expect(NodeFS.statSync(path + ".txt").mtimeMs).toBe(before); + yield* Effect.gen(function* () { + const cleanup = yield* Cleanup.ResourceCleanupService; + yield* cleanup.cleanupAttachments([file.id]); + expect(NodeFS.existsSync(path)).toBe(false); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + NodeFS.writeFileSync(path + ".txt", "orphaned extraction"); + yield* cleanup.cleanupAttachments([file.id]); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + }).pipe( + Effect.provide( + Cleanup.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ), + ), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect( + "preserves corrupt documents, PDFs and unknown binaries without an extracted path", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + for (const extension of ["docx", "ipynb", "zip", "pdf"]) { + const file = { + ...attachment, + id: attachment.id.replace(/txt$/, extension), + name: `broken.${extension}`, + }; + const path = NodePath.join(config.attachmentsDir, `${file.id}.${extension}`); + NodeFS.writeFileSync(path, "invalid document"); + const answers = yield* appendUserInputAttachmentPaths({ + answers: {}, + attachmentsDir: config.attachmentsDir, + attachmentsByQuestionId: { q: [file] }, + }); + expect(answers.q).toContain(path); + expect(answers.q).not.toContain("text extracted"); + expect(NodeFS.existsSync(`${path}.txt`)).toBe(false); + expect(NodeFS.readFileSync(path, "utf8")).toBe("invalid document"); + } + }).pipe(Effect.provide(layer)), + ); + + it.effect("preserves office structure and EPUB reading order with bounded archives", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const cases = [ + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": "

Readable despite embedded media

", + "word/media/black.bmp": "\0".repeat(64 * 1024), + "word/embeddings/unused.bin": "\0".repeat(33 * 1024 * 1024), + }, + expected: ["Readable despite embedded media"], + absent: ["[Extracted text truncated"], + }, + ...[false, true].map((deep) => ({ + ext: "docx", + bounded: true, + parts: { + "word/document.xml": `${deep ? "
".repeat(129) + "Lost" + "
".repeat(129) : "

".repeat(200_001)}`, + }, + expected: ["[Extracted text truncated at document extraction limits.]"], + absent: ["Lost"], + })), + ...["ods", "odt", "odp", "docx"].flatMap((ext) => + [false, true].map((rows) => ({ + ext, + bounded: true, + parts: { + [ext === "docx" ? "word/document.xml" : "content.xml"]: + `

Repeated value

`, + }, + expected: [ + "Repeated value", + "[Extracted text truncated at document extraction limits.]", + ], + occurrences: { text: "Repeated value", count: rows ? 10_000 : 512 }, + })), + ), + ...["ods", "odt", "odp", "docx"].map((ext) => ({ + ext, + bounded: true, + parts: { + [ext === "docx" ? "word/document.xml" : "content.xml"]: + `

Kept

\n

\t

\n

\t

`, + }, + expected: ["Kept"], + absent: ["[Extracted text truncated", "\t"], + })), + ...["ods", "odt", "odp"].map((ext) => ({ + ext, + bounded: true, + parts: { + "content.xml": ` + + + + + + + +

\n

+

Displayed

+ + + +
`, + }, + expected: [ + "Harbor\tfalse\t2026-10-20\tPT2H30M\t125.50 USD\t0.25 [ODF value-type=percentage]\t0\tFallback\tDisplayed", + ], + absent: ["Unshown", "[Extracted text truncated"], + })), + ...[false, true].map((rows) => ({ + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": '', + "xl/_rels/workbook.xml.rels": + '', + "xl/worksheets/a.xml": `${rows ? "7".repeat(10_001) : '7Beyond'}`, + }, + expected: ["7", "[Extracted text truncated at document extraction limits.]"], + absent: ["Beyond"], + })), + { + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": '', + "xl/_rels/workbook.xml.rels": + '', + "xl/sharedStrings.xml": " \n Present", + "xl/worksheets/a.xml": `${'0 \t '.repeat(10_001)}01 \n `, + }, + expected: ["Present"], + absent: ["[Extracted text truncated"], + }, + { + ext: "epub", + parts: { + "META-INF/container.xml": '', + "book.opf": + '', + "good.svg": + 'Readable SVG chapter', + }, + expected: [ + "Readable SVG chapter", + "[Skipped document part: chapter 1 (missing or invalid XML).]", + ], + absent: ["[Extracted text truncated"], + }, + ...[false, true].flatMap((bom) => + [false, true].map((bigEndian) => { + const encode = (source: string) => { + const data = Buffer.from(`${bom ? "\ufeff" : ""}${source}`, "utf16le"); + return bigEndian ? data.swap16() : data; + }; + return { + ext: "epub", + parts: { + "META-INF/container.xml": encode( + '', + ), + "book.opf": encode( + '', + ), + "a.xhtml": encode( + '

UTF-16 chapter: café 日本語

', + ), + }, + expected: ["UTF-16 chapter: café 日本語"], + absent: ["[Skipped document part", "[Extracted text truncated"], + }; + }), + ), + { + ext: "epub", + bounded: true, + parts: { + "META-INF/container.xml": '', + "book.opf": + '', + "bad.xhtml": "

Discard this broken chapter", + "good.xhtml": + '

North Harbor ≂̸ ∳ & <tag> "quoted"

 
Kept cell
', + }, + expected: [ + "## Chapter 2", + 'North\u00a0Harbor ≂̸ ∳ & "quoted"', + "Literal  ", + "Kept cell", + "[Skipped document part: bad.xhtml (missing or invalid XML).]", + ], + absent: ["Discard this broken chapter", "[Extracted text truncated"], + }, + ...["pptx", "xlsx"].map((ext) => ({ + ext, + bounded: true, + parts: + ext === "pptx" + ? { + "ppt/presentation.xml": + '', + "ppt/_rels/presentation.xml.rels": + '', + "ppt/bad.xml": "

Discard", + "ppt/good.xml": "

Healthy part

", + } + : { + "xl/workbook.xml": + '', + "xl/_rels/workbook.xml.rels": + '', + "xl/bad.xml": "Discard", + "xl/good.xml": + 'Healthy part', + }, + expected: [ + "Healthy part", + `[Skipped document part: ${ext === "pptx" ? "ppt" : "xl"}/bad.xml (missing or invalid XML).]`, + ], + absent: ["Discard", "[Extracted text truncated"], + })), + { + ext: "odt", + bounded: true, + parts: { "content.xml": '

StartUnneeded

' }, + expected: ["Start", "[Extracted text truncated at document extraction limits.]"], + absent: ["Unneeded"], + }, + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": + 'Report & totalsNorth1234BeforeInnerAfterNeighbor', + }, + expected: ["## Report & totals", "North\t12 34", "Before Inner After\tNeighbor"], + }, + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": + 'FirstSecondThird', + }, + expected: ["First\nSecond\nThird"], + }, + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": `${"A".repeat(1_000_000)}Later cell`, + }, + expected: ["A".repeat(32), "[Extracted text truncated at document extraction limits.]"], + }, + { + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": '', + "xl/_rels/workbook.xml.rels": + '', + "xl/styles.xml": '', + "xl/worksheets/a.xml": + "SUM(11,22)SUM(33,44) \n SUM(55,66)SUM(11,22)33SUM(0,0)0", + }, + expected: [ + "SUM(11,22)\nSUM(33,44)\nSUM(55,66)", + "33 [Excel numFmtId=9; date system 1900]\t0 [Excel numFmtId=9; date system 1900]", + ], + absent: ["SUM(0,0)", "[Extracted text truncated"], + }, + { + ext: "xlsx", + parts: { + "xl/workbook.xml": + '', + "xl/_rels/workbook.xml.rels": + '', + "xl/sharedStrings.xml": "Lantern", + "xl/styles.xml": + '', + "xl/worksheets/second.xml": + '037', + "xl/worksheets/first.xml": + 'Ferry628452920.2545293', + }, + expected: [ + "## Sheet: Inventory", + "Lantern\t\t37", + "## Sheet: Budget", + "Ferry\t628", + "45292 [Excel numFmtId=14; date system 1900]", + "0.25 [Excel numFmtId=9; date system 1900]", + "45293 [Excel numFmtId=164; format=yyyy-mm-dd; date system 1900]", + ], + }, + { + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": '', + "xl/_rels/workbook.xml.rels": + '', + "xl/sharedStrings.xml": + '東京とうきょう', + "xl/worksheets/a.xml": + '0東京とうきょう', + }, + expected: ["## Sheet: Phonetics", "東京\t東京"], + absent: ["とうきょう"], + }, + { + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": + '', + "xl/_rels/workbook.xml.rels": + '', + "xl/styles.xml": '', + "xl/worksheets/dates.xml": + "1", + }, + expected: ["## Sheet: Dates", "1 [Excel numFmtId=14; date system 1904]"], + }, + { + ext: "xlsx", + bounded: true, + parts: { + "xl/workbook.xml": + '', + "xl/_rels/workbook.xml.rels": + '', + "xl/sharedStrings.xml": `${"A".repeat(1_000_000 - "## Sheet: Repeated\n".length)}`, + "xl/worksheets/repeated.xml": `${'00'.repeat(1_500)}`, + }, + expected: [ + "## Sheet: Repeated", + "A".repeat(32), + "[Extracted text truncated at document extraction limits.]", + ], + }, + { + ext: "pptx", + bounded: true, + parts: { + "ppt/presentation.xml": + '', + "ppt/_rels/presentation.xml.rels": + '', + "ppt/slides/slide1.xml": "Departure", + "ppt/slides/slide2.xml": "Juniper Pier", + "ppt/slides/_rels/slide2.xml.rels": + '', + "ppt/slides/_rels/slide1.xml.rels": + '', + "ppt/notesSlides/notes7.xml": + "evacuation code ORCHID-81", + "ppt/notesSlides/notes3.xml": + "Board at gate 4", + "ppt/notesSlides/ignored.xml": "Unrelated notes", + }, + expected: [ + "## Slide 1", + "Juniper Pier\nNotes:\nevacuation code ORCHID-81", + "## Slide 2", + "Departure\nNotes:\nBoard at gate 4", + ], + absent: ["Unrelated notes", "[Skipped document part", "[Extracted text truncated"], + }, + ...[false, true].map((missing) => ({ + ext: "pptx", + bounded: true, + parts: { + "ppt/presentation.xml": '', + "ppt/_rels/presentation.xml.rels": + '', + "ppt/slides/a.xml": "

Slide text

", + "ppt/slides/_rels/a.xml.rels": ``, + "ppt/notesSlides/bad.xml": "

Discard broken notes", + }, + expected: [ + "Slide text", + `[Skipped document part: ppt/notesSlides/${missing ? "missing" : "bad"}.xml (missing or invalid XML).]`, + ], + absent: ["Discard broken notes", "[Extracted text truncated"], + })), + { + ext: "pptx", + bounded: true, + parts: { + "ppt/presentation.xml": + '', + "ppt/_rels/presentation.xml.rels": + '', + "ppt/slides/a.xml": `

${"A".repeat(600_000)}

`, + "ppt/slides/b.xml": "

Unneeded slide

", + "ppt/slides/_rels/a.xml.rels": + '', + "ppt/notesSlides/a.xml": `

evacuation code ORCHID-81 ${"B".repeat(600_000)} Unneeded notes

`, + }, + expected: [ + "## Slide 1", + "A".repeat(32), + "Notes:\nevacuation code ORCHID-81", + "B".repeat(32), + "[Extracted text truncated at document extraction limits.]", + ], + absent: ["Unneeded notes", "Unneeded slide", "## Slide 2"], + }, + { + ext: "odt", + parts: { + "content.xml": + 'Orchard19violetcrates', + }, + expected: ["## Orchard", "19 violet crates"], + }, + { + ext: "ods", + parts: { + "content.xml": + 'Violet', + }, + expected: ["## Sheet: Cargo", "Violet\t19\t27"], + }, + { + ext: "ods", + bounded: true, + parts: { + "content.xml": + 'LeftRight', + }, + expected: ["## Sheet: Padded rows", "Left\t\t\tRight"], + absent: ["[Extracted text truncated"], + }, + { + ext: "ods", + bounded: true, + parts: { + "content.xml": + 'Populated', + }, + expected: ["## Sheet: Padded", "Populated"], + absent: ["[Extracted text truncated", "\t"], + }, + { + ext: "ods", + bounded: true, + parts: { + "content.xml": `Lead ${"a".repeat(600_000)} Tail`, + }, + expected: [ + "## Sheet: Repeated", + "Lead ", + " Tail\tLead ", + "[Extracted text truncated at document extraction limits.]", + ], + }, + { + ext: "odp", + parts: { + "content.xml": + "FirstSecond", + }, + expected: ["## Slide 1", "First", "## Slide 2", "Second"], + }, + { + ext: "ods", + bounded: true, + parts: { + "content.xml": `${Array.from( + { length: 256 }, + (_, index) => + `${"B".repeat(4096)}`, + ).join("")}`, + }, + expected: [ + "## Sheet: Shared", + "B".repeat(32), + "[Extracted text truncated at document extraction limits.]", + ], + absent: ["## Sheet: Unneeded"], + }, + { + ext: "odt", + bounded: true, + parts: { + "content.xml": `Before whitespace${" ".repeat(10_000)}Unneeded`, + }, + expected: ["Before whitespace", "Unneeded"], + absent: ["[Extracted text truncated"], + }, + ...["odt", "odp", "ods"].flatMap((ext) => + [false, true].map((rows) => ({ + ext, + bounded: true, + parts: { + "content.xml": `${Array.from( + { length: 256 }, + () => + `${"Budget ".repeat(rows ? 160 : 585)}`, + ).join( + "", + )}Unneeded`, + }, + expected: ["Budget ", "[Extracted text truncated at document extraction limits.]"], + absent: ["Unneeded"], + })), + ), + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": `${"A".repeat(1_000_100)}Unneeded`, + }, + expected: ["A".repeat(32), "[Extracted text truncated at document extraction limits.]"], + absent: ["Unneeded"], + }, + { + ext: "pptx", + bounded: true, + parts: { + "ppt/presentation.xml": + '', + "ppt/_rels/presentation.xml.rels": + '', + "ppt/slides/slide.xml": `${"A".repeat(1_000_100)}`, + }, + expected: [ + "## Slide 1", + "A".repeat(32), + "[Extracted text truncated at document extraction limits.]", + ], + absent: ["## Slide 2"], + }, + { + ext: "epub", + bounded: true, + parts: { + "META-INF/container.xml": '', + "book.opf": + '', + "a.xhtml": `

${"A".repeat(1_000_100)}

`, + }, + expected: [ + "## Chapter 1", + "A".repeat(32), + "[Extracted text truncated at document extraction limits.]", + ], + absent: ["## Chapter 2"], + }, + { + ext: "epub", + parts: { + "META-INF/container.xml": + '', + "OEBPS/book.opf": + '', + "OEBPS/a.xhtml": + '

Last chapter

Price 10 8

', + "OEBPS/b.xhtml": + "

First chapter

Larkspur

Direct div
Direct section
  • Direct item emphasis trailing
  • Account: A
    Balance: 731Currency: USD
    HeadingTotal
    Important value731
    Before
    Inner
    After
    Neighbor
    ", + }, + expected: [ + "## Chapter 1", + "# First chapter", + "Larkspur", + "Direct div", + "Direct section", + "Direct item emphasis trailing", + "Account: A\nBalance: 731\nCurrency: USD", + "Heading\tTotal", + "Important value\t731", + "Before Inner After\tNeighbor", + "## Chapter 2", + "Last chapter", + "Price 10 8", + ], + }, + ]; + for (const [caseIndex, item] of cases.entries()) { + const local: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const [name, content] of Object.entries(item.parts)) { + const data = Buffer.from(content!); + const compressed = /\.(bmp|bin)$/.test(name) ? NodeZlib.deflateRawSync(data) : data; + const filename = Buffer.from(name); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt16LE(20, 4); + header.writeUInt16LE(compressed === data ? 0 : 8, 8); + header.writeUInt32LE(NodeZlib.crc32(data), 14); + header.writeUInt32LE(compressed.length, 18); + header.writeUInt32LE(data.length, 22); + header.writeUInt16LE(filename.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt16LE(20, 4); + directory.writeUInt16LE(20, 6); + directory.writeUInt16LE(compressed === data ? 0 : 8, 10); + directory.writeUInt32LE(NodeZlib.crc32(data), 16); + directory.writeUInt32LE(compressed.length, 20); + directory.writeUInt32LE(data.length, 24); + directory.writeUInt16LE(filename.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, filename, compressed); + central.push(directory, filename); + offset += header.length + filename.length + compressed.length; + } + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(central.length / 2, 8); + end.writeUInt16LE(central.length / 2, 10); + end.writeUInt32LE(Buffer.concat(central).length, 12); + end.writeUInt32LE(offset, 16); + const path = NodePath.join(config.attachmentsDir, `structure-${caseIndex}.${item.ext}`); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + const extracted = yield* Effect.promise(() => extractDocumentText(path, `.${item.ext}`)); + let previous = -1; + for (const expected of item.expected) { + const index = extracted.indexOf(expected); + expect(index, `${item.ext}: ${expected} in ${extracted.slice(0, 500)}`).toBeGreaterThan( + previous, + ); + previous = index; + } + if ("occurrences" in item) + expect(extracted.split(item.occurrences.text).length - 1).toBe(item.occurrences.count); + if ("absent" in item) + for (const absent of item.absent) expect(extracted).not.toContain(absent); + if (("bounded" in item && item.bounded) || item.ext === "epub") { + expect(extracted.length).toBeLessThan(1_000_100); + expect(extracted.match(/\[Extracted text truncated/g)?.length ?? 0).toBeLessThanOrEqual( + 1, + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(extracted); + } + if (item.ext === "docx") { + end.writeUInt16LE(2049, 8); + end.writeUInt16LE(2049, 10); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".docx")).toContain("[Extracted text truncated"); + }); + end.writeUInt16LE(central.length / 2, 8); + end.writeUInt16LE(central.length / 2, 10); + central[0]!.writeUInt16LE(8, 10); + central[0]!.writeUInt32LE(33 * 1024 * 1024, 24); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".docx")).toContain("[Extracted text truncated"); + }); + } + } + const path = NodePath.join(config.attachmentsDir, "unicode.rtf"); + NodeFS.writeFileSync( + path, + String.raw`{\rtf1\ansi{\fonttbl hidden}Hello\tab world\par Unicode \u945?{\*\secret hidden}}`, + ); + const extracted = yield* Effect.promise(() => extractDocumentText(path, ".rtf")); + expect(extracted).toContain("Hello\tworld\nUnicode α"); + expect(extracted).not.toContain("hidden"); + + for (const [name, content, expected] of [ + [ + "cyrillic-escaped", + String.raw`{\rtf1\ansi\ansicpg1251 \'cf\'f0\'e8\'e2\'e5\'f2 {\ansicpg1252 \'e9} \'cc\'ee\'f1\'ea\'e2\'e0}`, + "Привет é Москва", + ], + [ + "cyrillic-raw", + Buffer.concat([ + Buffer.from(String.raw`{\rtf1\ansi\ansicpg1251 `), + Buffer.from([0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2]), + Buffer.from("}"), + ]), + "Привет", + ], + ] as const) { + const encodedPath = NodePath.join(config.attachmentsDir, `${name}.rtf`); + NodeFS.writeFileSync(encodedPath, content); + expect(yield* Effect.promise(() => extractDocumentText(encodedPath, ".rtf"))).toBe( + expected, + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(encodedPath)).toBe(encodedPath + ".txt"); + expect(NodeFS.readFileSync(encodedPath + ".txt", "utf8")).toBe(expected); + } + const notebookPath = NodePath.join(config.attachmentsDir, "limited.ipynb"); + NodeFS.writeFileSync( + notebookPath, + JSON.stringify({ cells: Array.from({ length: 10_001 }, () => ({ source: "Kept" })) }), + ); + const notebookLimited = yield* Effect.promise(() => + extractDocumentText(notebookPath, ".ipynb"), + ); + expect(notebookLimited).toContain("## Cell 10000"); + expect(notebookLimited).not.toContain("## Cell 10001"); + expect(notebookLimited.match(/\[Extracted text truncated/g)).toHaveLength(1); + expect(yield* (yield* DocumentText.DocumentText).ensure(notebookPath)).toBe( + notebookPath + ".txt", + ); + expect(NodeFS.readFileSync(notebookPath + ".txt", "utf8")).toBe(notebookLimited); + const errorPath = NodePath.join(config.attachmentsDir, "limited-error.ipynb"); + NodeFS.writeFileSync( + errorPath, + JSON.stringify({ + cells: [ + { + cell_type: "code", + source: "1 / 0", + outputs: [ + { + output_type: "error", + ename: "ZeroDivisionError", + evalue: "division by zero", + traceback: ["\u001b[31m" + "trace".repeat(250_000) + "\u001b[0m", "After limit"], + }, + ], + }, + ], + }), + ); + const errorLimited = yield* Effect.promise(() => extractDocumentText(errorPath, ".ipynb")); + expect(errorLimited).toContain("ZeroDivisionError: division by zero\ntrace"); + expect(errorLimited.length).toBeLessThan(1_000_100); + expect(errorLimited.match(/\[Extracted text truncated/g)).toHaveLength(1); + expect(errorLimited).not.toContain("After limit"); + expect(errorLimited).not.toContain("\u001b"); + const deepPath = NodePath.join(config.attachmentsDir, "deep.rtf"); + NodeFS.writeFileSync( + deepPath, + String.raw`{\rtf1 Kept` + "{".repeat(128) + "Lost" + "}".repeat(129), + ); + const deepLimited = yield* Effect.promise(() => extractDocumentText(deepPath, ".rtf")); + expect(deepLimited).toBe("Kept\n\n[Extracted text truncated at document extraction limits.]"); + const unsupportedPath = NodePath.join(config.attachmentsDir, "unsupported-codepage.rtf"); + NodeFS.writeFileSync(unsupportedPath, String.raw`{\rtf1\ansi\ansicpg932 \'82\'a0}`); + yield* Effect.promise(async () => { + await expect(extractDocumentText(unsupportedPath, ".rtf")).rejects.toThrow( + "Unsupported RTF code page", + ); + }); + expect(yield* (yield* DocumentText.DocumentText).ensure(unsupportedPath)).toBeUndefined(); + expect(NodeFS.existsSync(unsupportedPath + ".txt")).toBe(false); + NodeFS.writeFileSync(path, String.raw`{\rtf1 ` + "x".repeat(1_000_100) + "}"); + expect(yield* Effect.promise(() => extractDocumentText(path, ".rtf"))).toContain( + "[Extracted text truncated", + ); + NodeFS.writeFileSync(path, String.raw`{\rtf1 First` + " ".repeat(1_000_100) + "Last}"); + const whitespaceTruncated = yield* Effect.promise(() => extractDocumentText(path, ".rtf")); + expect(whitespaceTruncated).toBe( + "First\n\n[Extracted text truncated at document extraction limits.]", + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(whitespaceTruncated); + NodeFS.truncateSync(path, 17 * 1024 * 1024); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".rtf")).toContain("[Extracted text truncated"); + }); + }).pipe(Effect.provide(layer)), + ); it.effect("keeps selected values intact and appends an actual readable server path", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/provider/userInputAttachments.ts b/apps/server/src/provider/userInputAttachments.ts index e1620006621b4..96ea22a2690b3 100644 --- a/apps/server/src/provider/userInputAttachments.ts +++ b/apps/server/src/provider/userInputAttachments.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as DocumentText from "./DocumentText.ts"; class UserInputAttachmentError extends Schema.TaggedError()( "UserInputAttachmentError", { @@ -23,6 +24,7 @@ export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachme }) { const answers = new Map(Object.entries(input.answers)); const fs = yield* FileSystem.FileSystem; + const documents = yield* DocumentText.DocumentText; for (const [questionId, attachments] of Object.entries(input.attachmentsByQuestionId ?? {})) { if (attachments.length === 0) continue; const references: string[] = []; @@ -46,8 +48,9 @@ export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachme issue: `Attachment '${attachment.name}' is no longer available. Attach it again.`, }); } + const extracted = yield* documents.ensure(path); references.push( - `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}`, + `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}${extracted ? ` (text extracted to: ${quoteReference(extracted)})` : ""}`, ); } const answer = answers.get(questionId); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 72cd314f5e28c..63323b6122d20 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -24,9 +24,7 @@ export function fromProviders( }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = Effect.gen(function* () { const drivers = yield* BuiltInDrivers.SourceControlDrivers; return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts index 2d2fcfc1f05a8..2485be7a97a30 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.test.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -1,3 +1,5 @@ +import * as NodeV8 from "node:v8"; +import * as NodeVM from "node:vm"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { PullRequestOperationError } from "@t3tools/contracts"; @@ -8,6 +10,9 @@ import * as FileSystem from "effect/FileSystem"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import * as PullRequestReadCache from "./PullRequestReadCache.ts"; +NodeV8.setFlagsFromString("--expose-gc"); +const collectGarbage = NodeVM.runInNewContext("gc") as () => void; + const cacheLayer = (directory: string) => PullRequestReadCache.make.pipe(Effect.provide(KeyValueStore.layerFileSystem(directory))); @@ -147,6 +152,32 @@ it.layer(NodeServices.layer)("PR filesystem cache", (it) => { }).pipe(Effect.provide(KeyValueStore.layerMemory)), ); + it.effect("does not keep a caller's data alive after its reads and invalidations", () => + Effect.gen(function* () { + const cache = yield* PullRequestReadCache.make; + const sweeps: Array> = []; + // Like a sync sweep: an Effect.fn made per run that closes over that run's data. + const sweep = Effect.fn("sweep")(function* (index: number) { + const groups = new Map( + Array.from({ length: 1_000 }, (_, id) => [`group-${id}`, id]), + ); + sweeps.push(new WeakRef(groups)); + const read = Effect.fn("read")(function* (pr: string) { + const size = groups.size; + yield* cache.invalidate(pr); + const answer = yield* cache.get(`${pr}-${index}`, Effect.succeed("answer"), [pr]); + return size + answer.length; + }); + yield* Effect.forEach(["pr-1", "pr-2"], read, { discard: true }); + }); + for (let index = 0; index < 5; index++) yield* sweep(index); + // WeakRef targets stay alive until the current job ends, so yield before collecting. + yield* Effect.yieldNow; + collectGarbage(); + assert.strictEqual(sweeps.filter((sweepData) => sweepData.deref() !== undefined).length, 0); + }).pipe(Effect.provide(KeyValueStore.layerMemory)), + ); + it.effect("cancels abandoned reads without blocking invalidation", () => Effect.gen(function* () { const cache = yield* PullRequestReadCache.make; diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 8d9691fd79e30..62ac7da3789d6 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -21,11 +21,11 @@ import * as Persistable from "effect/persistence/Persistable"; import * as PersistedCache from "effect/persistence/PersistedCache"; import * as Persistence from "effect/persistence/Persistence"; import * as ServerConfig from "../config.ts"; +import { detachStackFrame } from "../git/detachStackFrame.ts"; import { forkParked } from "../serverActivation.ts"; const CONCURRENT_READS = 512; -// Persistence prefixes every entry key with the store id, so each entry file -// name starts with it. The scope `revisions` file does not. +// Entry filenames have the store-id prefix; the scope revisions file does not. const STORE_ID = "pr-v2"; const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); /** @@ -125,6 +125,8 @@ export const make = Effect.gen(function* () { }), { capacity: CONCURRENT_READS, timeToLive: () => Duration.zero }, ); + // A cache entry keeps the fiber that filled it, whose lazy stack frames would keep the caller's + // data (e.g. a whole sync sweep) alive until eviction. return PullRequestReadCache.of({ get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup, scopes = []) { if (!enabled) return yield* lookup; @@ -152,7 +154,7 @@ export const make = Effect.gen(function* () { }), lock.withPermits(1), ); - }), + }, detachStackFrame), invalidate: Effect.fn("PullRequestReadCache.invalidate")( function* (scope) { const now = clock.currentTimeMillisUnsafe(); @@ -171,6 +173,7 @@ export const make = Effect.gen(function* () { }), Effect.uninterruptible, lock.withPermits(CONCURRENT_READS), + detachStackFrame, ), }); }); diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index 91b4b94e97d9b..e0dc7309289b6 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -75,8 +75,7 @@ const make = Effect.gen(function* () { const serviceScope = yield* Effect.scope; const runs = yield* Ref.make>(new Map()); let runCounter = 0; - // Claiming a run and storing a finished guide take turns, so a run that still owns its pull - // request when it checks is the one whose guide is stored: a newer claim cannot land between. + // Serialize claims and guide writes so a newer claim cannot steal ownership between checking and storing. const ownership = yield* Semaphore.make(1); const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* ( @@ -195,8 +194,7 @@ const make = Effect.gen(function* () { args: ["fetch", "--quiet", "--no-tags", "--", remote, ...args], timeoutMs: 5 * 60_000, }); - // The host names the base branch. A name git rejects, or one that would change the refspec - // (a leading `+`, a `:`), is refused before it reaches `git fetch`. + // Reject invalid branch names and refspec operators before fetching host-provided names. yield* git .execute({ operation: "PullRequestReviewGuideService.checkBaseBranch", @@ -213,8 +211,7 @@ const make = Effect.gen(function* () { }), ), ); - // Fetched into a ref of this run's own: FETCH_HEAD is shared by every fetch in the - // workspace, so a concurrent run could otherwise measure this change from its base. + // FETCH_HEAD is workspace-global; isolate this run's base from concurrent fetches. const baseRef = `refs/t3/review-guide/${runId}/base`; yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]); yield* Effect.addFinalizer(() => @@ -239,7 +236,7 @@ const make = Effect.gen(function* () { detail: `The host reported a head for #${number} that is not a commit hash.`, }); } - // Not redundant: rev-parse prefers a branch or tag named like a short hash over the commit. + // rev-parse prefers same-named branches/tags over short hashes. const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe( Effect.filterOrFail( ({ commitSha }) => commitSha.startsWith(sha), @@ -333,8 +330,7 @@ const make = Effect.gen(function* () { .execute({ operation: "PullRequestReviewGuideService.readChange", cwd, - // Fixed prefixes: a user's diff.noPrefix or custom prefixes would hide the headers the - // parser reads. + // Override user diff prefixes so the parser can find headers. args: [ "-c", "core.quotePath=false", @@ -463,9 +459,7 @@ const make = Effect.gen(function* () { headSha: currentHeadSha, startedAt: yield* nowIso, }; - // Claimed atomically, so two readers pressing Generate start one run between them. The head - // is read again under the claim: a press that read an older head before a push must not - // replace a run another press started for the new one, so it asks to be pressed again. + // Claim atomically and re-read the head so stale clicks cannot replace a newer head's run. const claimed = yield* ownership.withPermits(1)( pullRequests.detail(input).pipe( Effect.flatMap((latest) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 429132731ef3f..4b2d67738c20c 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -199,8 +199,7 @@ function makeService(input: { readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"]; }) { - // Built into the test's own scope rather than provided call by call: the marks store owns a - // database, and `Effect.provide` would close it the moment the service was handed back. + // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition. return Effect.flatMap( Layer.build( Layer.mergeAll( @@ -1434,15 +1433,6 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [] of [ - ["github", "github.com"], - ["gitlab", "gitlab.com"], - ["forgejo", "code.example.test"], - ["bitbucket", "bitbucket.org"], - ["azure-devops", "dev.azure.com"], -] as const) { -} - it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { const service = yield* makeService({ @@ -2664,6 +2654,9 @@ it.effect("isolates routed caches for two credentials belonging to the same acco host: "github.com", expectedAccountId: "101", }; + if (operation === "checks") { + yield* service.withRoutingCredential(reference, service.detail(reference)); + } yield* service.withRoutingCredential(reference, readOperation(reference)); credential = "restricted"; for (const allowStale of [false, true]) { @@ -2839,6 +2832,48 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => }), ); +it.effect( + "reuses fresh detail checks without extending their refresh interval on detail cache hits", + () => + Effect.gen(function* () { + let reads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + getChangeRequestChecks: () => + Effect.sync(() => { + reads += 1; + return { state: "closed" as const, checks: [] }; + }), + }), + ], + }); + yield* TestClock.setTime(1_000); + const detail = yield* service.detail(reference); + expect(yield* service.checks(reference)).toEqual({ + state: detail.state, + checks: detail.checks, + observedAt: detail.observedAt, + }); + expect(reads).toBe(0); + yield* TestClock.adjust("16 seconds"); + yield* service.detail(reference); + expect(yield* service.checks(reference)).toMatchObject({ + state: "closed", + observedAt: 17_000, + }); + expect(reads).toBe(1); + yield* service.detail(reference); + expect((yield* service.checks(reference))?.state).toBe("closed"); + expect(reads).toBe(1); + }), +); + it.effect("preserves check observation time on cache hits and advances it on refresh", () => Effect.gen(function* () { let reads = 0; @@ -3982,78 +4017,353 @@ it.effect("pauses uncached activity on a host after one activity read is rate li }), ); -it.effect("expands only files the change request changes, across every slice of its diff", () => +it.effect("authorizes cold source reads with paginated paths without fetching patch hunks", () => Effect.gen(function* () { - const read: Array = []; + const pages: Array = []; + const contents: Array = []; const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ fakePullRequestProvider(SourceControlProviderKind.make("github"), { - getDiff: (input) => - Effect.succeed( - input.cursor === undefined - ? { - patch: [ - "diff --git a/src/old.ts b/src/new.ts", - "rename from src/old.ts", - "rename to src/new.ts", - "--- a/src/old.ts", - "+++ b/src/new.ts", - "", - ].join("\n"), - truncated: false, - nextCursor: "2", - } - : { - patch: [ - "diff --git a/gone.md b/gone.md", - "deleted file mode 100644", - "--- a/gone.md", - "+++ /dev/null", - "", - ].join("\n"), - truncated: false, - nextCursor: null, - }, - ), + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.sync(() => { + pages.push(input.cursor); + return input.cursor === undefined + ? { files: [{ oldPath: "before.ts", newPath: "after.ts" }], nextCursor: "next" } + : { files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], nextCursor: null }; + }), getDiffFileContents: (input) => Effect.sync(() => { - read.push(`${input.oldPath}->${input.newPath}`); - return { oldContents: "old", newContents: "new" }; + contents.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "before", newContents: "after" }; }), }), ], }); - const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; yield* service.diffFileContents({ ...ref, changeType: "rename-changed", - oldPath: "src/old.ts", - newPath: "src/new.ts", + oldPath: "before.ts", + newPath: "after.ts", }); yield* service.diffFileContents({ ...ref, changeType: "deleted", - oldPath: "gone.md", - newPath: "gone.md", + oldPath: "gone.ts", + newPath: "gone.ts", }); - const outside = yield* service - .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) - .pipe(Effect.flip); - const halfRename = yield* service - .diffFileContents({ + for (const [oldPath, newPath] of [ + [".env", ".env"], + ["../after.ts", "../after.ts"], + ["secret.ts", "after.ts"], + ] as const) { + const error = yield* service + .diffFileContents({ ...ref, changeType: "change", oldPath, newPath }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + } + assert.deepStrictEqual(pages, [undefined, "next"]); + assert.deepStrictEqual(contents, ["before.ts->after.ts", "gone.ts->gone.ts"]); + }), +); + +it.effect.each(["commit", "credential", "expiry", "refresh"] as const)( + "keeps cold metadata membership in its original scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let credential = "broad"; + let pathReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.gen(function* () { + assert.strictEqual(input.cwd, "/a"); + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.repository, "acme/web"); + if (++pathReads !== 1) return { files: [], nextCursor: null }; + assert.strictEqual( + input.commit, + boundary === "commit" ? "a".repeat(40) : undefined, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + files: [{ oldPath: "private.ts", newPath: "private.ts" }], + nextCursor: null, + }; + }), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const initial = boundary === "commit" ? { ...input, commit: "a".repeat(40) } : input; + const loading = yield* Effect.forkChild( + service.withRoutingCredential(initial, service.diffFileContents(initial)), + ); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + if (boundary === "refresh") yield* service.invalidate({ reference: input }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary === "credential") credential = "restricted"; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(pathReads, 2); + assert.strictEqual(contentReads, 1); + }), +); + +it.effect( + "reuses loaded oversized diff slices to expand only files the change request changes", + () => + Effect.gen(function* () { + const read: Array = []; + const diffReads: Array = []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: (input) => + Effect.sync(() => { + diffReads.push(input.cursor); + return input.cursor === undefined + ? { + patch: [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + `+${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: "2", + } + : { + patch: [ + "diff --git a/gone.md b/gone.md", + "deleted file mode 100644", + "--- a/gone.md", + "+++ /dev/null", + `-${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: (input) => + Effect.sync(() => { + read.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; + + yield* service.diff(ref); + yield* service.diff({ ...ref, cursor: "2" }); + + yield* service.diffFileContents({ ...ref, - changeType: "change", - oldPath: "src/secret.ts", + changeType: "rename-changed", + oldPath: "src/old.ts", newPath: "src/new.ts", - }) - .pipe(Effect.flip); + }); + yield* service.diffFileContents({ + ...ref, + changeType: "deleted", + oldPath: "gone.md", + newPath: "gone.md", + }); + const outside = yield* service + .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) + .pipe(Effect.flip); + const halfRename = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "src/secret.ts", + newPath: "src/new.ts", + }) + .pipe(Effect.flip); + + assert.strictEqual(outside._tag, "PullRequestOperationError"); + assert.strictEqual(halfRename._tag, "PullRequestOperationError"); + assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); + assert.deepStrictEqual(diffReads, [undefined, "2"]); + }), +); - assert.strictEqual(outside._tag, "PullRequestOperationError"); - assert.strictEqual(halfRename._tag, "PullRequestOperationError"); - assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); - }), +// Refresh and expiry are covered by the in-flight test below; repository and number share the ref key. +it.effect.each(["commit", "credential"] as const)( + "does not reuse loaded diff membership after changing %s", + (boundary) => + Effect.gen(function* () { + let credential = "broad"; + let diffReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => + Effect.sync(() => ({ + patch: + ++diffReads === 1 + ? `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n` + : "", + truncated: false, + nextCursor: null, + })), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + }; + // Load at a commit and expand without one so the commit must be part of the membership key. + const loaded = boundary === "commit" ? { ...ref, commit: "a".repeat(40) } : ref; + yield* service.withRoutingCredential(loaded, service.diff(loaded)); + if (boundary === "credential") credential = "restricted"; + const input = { + ...ref, + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + assert.strictEqual(contentReads, 0); + }), +); + +it.effect.each(["expiry", "commit expiry", "cache hit", "commit cache hit", "refresh"] as const)( + "keeps a loaded diff's original membership scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let diffReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => + Effect.gen(function* () { + if (++diffReads !== 1) return { patch: "", truncated: false, nextCursor: null }; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + patch: `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n`, + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: () => Effect.succeed({ oldContents: "old", newContents: "new" }), + }), + ], + }); + const ref = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + ...(boundary.includes("commit") ? { commit: "a".repeat(40) } : {}), + }; + const loading = yield* Effect.forkChild(service.diff(ref)); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + else if (boundary === "commit expiry") yield* TestClock.adjust("11 minutes"); + else if (boundary.includes("cache hit")) yield* TestClock.adjust("30 seconds"); + else yield* service.invalidate({ reference: ref }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary.includes("cache hit")) { + yield* service.diff(ref); + yield* service.diff(ref); + yield* TestClock.adjust(boundary.includes("commit") ? "9 minutes" : "29 seconds"); + assert.deepStrictEqual( + yield* service.diffFileContents({ + ...ref, + changeType: "change", + oldPath: "private.ts", + newPath: "private.ts", + }), + { oldContents: "old", newContents: "new" }, + ); + assert.strictEqual(diffReads, 1); + yield* TestClock.adjust(boundary.includes("commit") ? "1 minute" : "2 seconds"); + } + const error = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "private.ts", + newPath: "private.ts", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8e23100377c40..5b654cd8683bd 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -86,6 +86,7 @@ import type { SourceControlProviderContext } from "@t3tools/source-control-core/ import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, + type ProviderDiffFilePaths, type ProviderFilterOptionPage, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, @@ -113,22 +114,13 @@ function supersedesReading(current: PullRequestReading | undefined, next: PullRe return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); } -/** - * 99 and not 100, because every provider asks its host for one row over this to probe for a next - * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses - * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab - * caps `per_page` at. - */ +// Providers request one extra row; 99 stays within GitHub/GitLab's 100-row API limit. const DEFAULT_REPOSITORY_LIST_LIMIT = 99; const REPOSITORY_CONCURRENCY = 12; const REPOSITORY_SEARCH_CHUNK = 100; const FILTERED_LIST_READS = 5; -/** - * Every read leaves the process — a CLI per repository, against hosts whose limits are low - * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short while and - * concurrent identical reads share one request. - */ +// Host reads spawn CLIs and hit low quotas; briefly share results and identical in-flight reads. const LIST_CACHE_TTL = Duration.seconds(30); const DETAIL_CACHE_TTL = Duration.seconds(60); const MERGED_DETAIL_CACHE_TTL = Duration.minutes(10); @@ -151,9 +143,23 @@ const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; -const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024; +// Each holder stays within this UTF-16 patch budget as well as its entry-count bound. +const DIFF_CACHE_PATCH_BUDGET_BYTES = 32 * 1024 * 1024; const canCacheDiff = (value: PullRequestDiffResult) => - value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES; + value.patch.length * 2 <= DIFF_CACHE_PATCH_BUDGET_BYTES; +const diffCacheEvictions = (entries: Iterable) => { + let bytes = 0; + const evictions: string[] = []; + for (const [key, value] of Array.from(entries).toReversed()) { + const size = value.patch.length * 2; + if (bytes + size > DIFF_CACHE_PATCH_BUDGET_BYTES) { + evictions.push(key); + } else { + bytes += size; + } + } + return evictions; +}; const FILES_VIEWED_CACHE_CAPACITY = 128; const VIEWER_CACHE_CAPACITY = 32; @@ -324,9 +330,7 @@ export interface SupportedProject { readonly api: PullRequestProviderApi; readonly repository: string; readonly host: string; - /** - * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. - */ + /** Unlike Azure repository names, remotes are unique across an organization. */ readonly remote: string; readonly repositoryKey: string | null; } @@ -371,10 +375,7 @@ function listCursorKey(host: string, repository: string): string { return `${host} ${repository.toLowerCase()}`; } -/** - * A slice that ends on the same instant it began on has to keep the earlier rows excluded as - * well as its own, or the read after it would hand them over again. - */ +// Preserve exclusions across slices sharing a timestamp to avoid redelivery. function nextListCursor( previous: ListCursor | undefined, fetched: ReadonlyArray, @@ -384,10 +385,7 @@ function nextListCursor( resumeAfter?: string, ): string | null { if (fetched.length === 0) return null; - // Taken from what the host answered rather than from what survived de-duplication: a slice can - // be entirely rows already sent — a hundred change requests touched in the same second is one - // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the - // walk on the instant it was stuck on, with everything older unreachable for good. + // Use all fetched rows: a slice containing only duplicates must still advance to older rows. const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance, resumeAfter); } @@ -573,6 +571,9 @@ function withRateLimitBackoff( }), getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions), getDiff: wrap("getDiff", api.getDiff), + ...(api.getDiffFilePaths === undefined + ? {} + : { getDiffFilePaths: wrap("getDiffFilePaths", api.getDiffFilePaths) }), ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), @@ -707,6 +708,7 @@ export const make = Effect.gen(function* () { const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; const readCache = yield* PullRequestReadCache.PullRequestReadCache; + const clock = yield* Clock.Clock; /** * Where each project's remote is served from, by the remote's recognised base URL: a remote no @@ -931,10 +933,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Azure derives its organization from the checkout, so it requires a matching - * repository. - */ + // Azure derives organization from the checkout; require the matching repository. const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { @@ -1037,12 +1036,7 @@ export const make = Effect.gen(function* () { }; }); - /** - * Every write goes through it: the page hides what a viewer may not do, and a request that - * arrived without passing through the page — or after the access behind it was withdrawn — must - * not be handed to a provider on the client's word. Read freshly for that reason, rather than - * taken from whatever the detail said when the page loaded. - */ + // Access can change after page load; recheck every write. const viewerPermissionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -1120,8 +1114,7 @@ export const make = Effect.gen(function* () { }, { capacity: VIEWER_CACHE_CAPACITY, - // This short entry exists to keep simultaneous cold page reads on one in-flight lookup; - // failures remain retryable. + // Share cold-page lookups briefly; keep failures retryable. timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, }, @@ -1142,16 +1135,12 @@ export const make = Effect.gen(function* () { } const forHost = projects.filter((project) => project.host === host); const api = forHost[0]!.api; - // Every checkout on the host, not just the ones that survived de-duplication: one - // unreadable worktree would otherwise report the whole host as signed out. + // Include deduplicated checkouts too; one unreadable worktree must not mark the whole host signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); if (options?.allowPaused === true) return Cache.get(viewerFlights, key); - // The pause is checked here rather than inside the lookup, so that it holds back the - // callers nobody is waiting on without splitting the flight they share with a press. - // A failed lookup is held nowhere, so letting a background read through would spawn - // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. + // Check pauses outside the shared lookup so background reads cannot spawn repeated failing CLIs and extend the pause. return rateLimits.check({ provider: api.kind, host }).pipe( Effect.flatMap(() => Cache.get(viewerFlights, key)), Effect.catch((error) => @@ -1174,10 +1163,7 @@ export const make = Effect.gen(function* () { { concurrency: REPOSITORY_CONCURRENCY }, ); - /** - * `checks` is absent because no listed row carries its check state: that one filter is - * the host's alone, and a row nobody narrowed stays rather than being guessed at. - */ + // Only the host can filter checks; listed rows have no check state. const matchesRowFilters = ( item: ProviderChangeRequest, filters: PullRequestListFilters | undefined, @@ -1592,7 +1578,7 @@ export const make = Effect.gen(function* () { } const result = yield* api .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, + { cwd: project.project.workspaceRoot, host: project.host, repository: project.repository }, (identity) => identity.accountId === input.expectedAccountId ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) @@ -1925,11 +1911,7 @@ export const make = Effect.gen(function* () { const scope = yield* Effect.scope; const runFork = Effect.runForkWith(context); - /** - * A lookup that failed is refused rather than answered as the unnamed reader: a momentarily - * signed-out CLI would otherwise hide every tick this reader has made and file the next press - * under rows that are orphaned once it recovers. - */ + // Refuse failed viewer lookups; unnamed fallbacks hide marks and orphan subsequent writes. const requiredViewerOf = ( project: SupportedProject, operation: string, @@ -1988,9 +1970,7 @@ export const make = Effect.gen(function* () { }), ); } - // A strategy the host does not offer must be refused rather than passed on: every - // provider maps an unrecognised method to its own default, so asking Azure DevOps to - // rebase would quietly merge instead of failing. + // Refuse unsupported strategies; providers silently map unrecognized methods to their default. if ( input.mergeMethod !== undefined && !project.api.capabilities.mergeMethods.includes(input.mergeMethod) @@ -2099,7 +2079,11 @@ export const make = Effect.gen(function* () { return input.action === "delete-branch" && project.api.withVerifiedCredential !== undefined ? project.api .withVerifiedCredential( - { cwd: project.project.workspaceRoot, host: project.host }, + { + cwd: project.project.workspaceRoot, + host: project.host, + repository: project.repository, + }, () => operation.pipe(Effect.result), ) .pipe( @@ -2154,12 +2138,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Neither is a question a permission read answers: every host lets the person who wrote - * something rewrite it whatever access they have otherwise, and none of them reports that as a - * permission — so a check here could only guess, and a wrong guess takes the control away from - * the one person certain to be allowed. - */ + // Hosts let authors edit without reporting that permission; checking it would wrongly deny access. const update: PullRequestService["Service"]["update"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2354,10 +2333,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Every host with reactions takes one from whoever can read the change request, so there - * is no access left to check that reading it has not already settled. - */ + // Hosts permit reactions from any reader; reading the PR already checks access. const setReaction: PullRequestService["Service"]["setReaction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2673,6 +2649,11 @@ export const make = Effect.gen(function* () { if (oldest !== undefined) held.delete(oldest); } held.set(key, { at, value }); + for (const evicted of diffCacheEvictions( + Array.from(held, ([key, snapshot]) => [key, snapshot.value] as const), + )) { + held.delete(evicted); + } armDiffSweep(); }); const serve = (key: string, read: Effect.Effect) => { @@ -2680,8 +2661,7 @@ export const make = Effect.gen(function* () { return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; - // Run as its own fiber rather than a child: the caller is answered and gone before the - // refresh lands. + // Use an independent fiber; refresh outlives the answered caller. return revalidate(recorded).pipe(Effect.as(snapshot.value)); }); }; @@ -2733,14 +2713,9 @@ export const make = Effect.gen(function* () { }, }), ); - const serveHeld = ( - key: string, - effect: Effect.Effect, - mode: "reuse" | "revalidate", - ) => { + const serveHeld = (key: string, effect: Effect.Effect) => { const snapshot = held.get(key); if (snapshot === undefined) return read(key, effect); - if (mode === "reuse") return Effect.succeed(snapshot.value); return revalidate(read(key, effect)).pipe(Effect.as(snapshot.value)); }; return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld }; @@ -2748,8 +2723,7 @@ export const make = Effect.gen(function* () { const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); - // The counter is shared and monotonic so a scope re-entering `refEpochs` after eviction can - // never mint a key an old entry still has. + // A shared monotonic counter prevents evicted scopes reusing epochs held by old entries. let epochCounter = 0; let listingsEpoch = 0; const refEpochs = new Map(); @@ -2780,8 +2754,6 @@ export const make = Effect.gen(function* () { } mergedEpochs.set(scope, projectEpochs.get(ref.projectId) ?? projectEpochFloor); }; - // Keys carry the reference back out of the cache loader, so the slot layout is shared with - // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: CredentialRef) => JSON.stringify([ refEpoch(ref), @@ -2994,8 +2966,9 @@ export const make = Effect.gen(function* () { ]); // A replacement reader can join a lookup still finishing its previous reader's cancellation. return Cache.get(listCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listCache, key), ), ); }; @@ -3024,7 +2997,20 @@ export const make = Effect.gen(function* () { }, { capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? CHECKS_CACHE_TTL : Duration.zero), + // Expire from the read's start so checks seeded from a slow detail read keep their age. + timeToLive: (exit) => + !Exit.isSuccess(exit) + ? Duration.zero + : exit.value === null + ? CHECKS_CACHE_TTL + : Duration.millis( + Math.max( + 0, + exit.value.observedAt + + Duration.toMillis(CHECKS_CACHE_TTL) - + clock.currentTimeMillisUnsafe(), + ), + ), }, ); @@ -3084,6 +3070,18 @@ export const make = Effect.gen(function* () { return detailUncached(refOfCacheKey(key)).pipe( Effect.tap( Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { + // GitHub detail includes the same checks; the checks TTL counts from their observation. + if ( + value.provider === "github" && + value.observedAt !== undefined && + !(yield* Cache.has(checksCache, key)) + ) { + yield* Cache.set(checksCache, key, { + state: value.state, + checks: value.checks, + observedAt: value.observedAt, + }); + } recordStats( statsKey, { @@ -3133,8 +3131,7 @@ export const make = Effect.gen(function* () { }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => supersedesReading(lastGoodSummary.peek(key), next); - // The newest state a detail read found per pull request, whatever cache epoch or credential - // it read under. Summary reads stay out: they would hide a change from the detail read after. + // Keep newest detail states across epochs/credentials; summary reads would mask subsequent detail changes. const detailStates = new Map(); const noteDetailReading = (ref: PullRequestRef, next: PullRequestSummary) => Effect.suspend(() => { @@ -3161,8 +3158,7 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); - // The last state each routed read reported, kept apart from `detailStates`: an unverified report - // must never outrank a reading this environment made itself. + // Keep routed reports separate; unverified reports must not outrank this environment's reads. const reportedStates = new Map< string, { @@ -3205,9 +3201,7 @@ export const make = Effect.gen(function* () { ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); - // Skip the write when that read is older than a later strict summary — - // display reuse would otherwise keep the regression and never ask the host - // again. + // Skip reads older than strict summaries; caching them would preserve regressions without asking the host again. const read = Cache.get(detailCache, key).pipe( Effect.tap((value) => { if (value.state === "merged") noteMerged(input); @@ -3223,7 +3217,7 @@ export const make = Effect.gen(function* () { ); return input.allowStale === false ? read.pipe(Effect.tap((value) => lastGoodDetail.record(key, value))) - : lastGoodDetail.serveHeld(key, read, "revalidate"); + : lastGoodDetail.serveHeld(key, read); }; const activityCache = yield* Cache.makeWith( @@ -3296,24 +3290,30 @@ export const make = Effect.gen(function* () { ...refOfCacheKey(reference), ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), - } as PullRequestDiffInput); + } as PullRequestDiffInput).pipe( + observeRead, + Effect.tap(({ value, observedAt }) => { + // Keep the loader's epoch and read time if a refresh occurs during the provider read. + diffFiles.record(JSON.stringify([reference, cursor, commit]), value, observedAt); + return Effect.void; + }), + ); }, { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; const commit = (JSON.parse(key) as ReadonlyArray)[2]; - return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; + const ttl = Duration.toMillis(commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL); + return Duration.millis( + Math.max(0, exit.value.observedAt + ttl - clock.currentTimeMillisUnsafe()), + ); }, }, ); - /** - * Both diff holders drop an expired value only when its own key is read again, and a diff's key - * moves with every push, so a closed page's patches would stay held until the server restarted. - */ + // Diffs expire only when their keys are reread; push-dependent keys leave closed pages' patches retained. let diffSweepPending = false; - // Forked from the service's own context, in one step with the flag: a caller's interruption - // cannot strand the flag, and its request is not kept alive by the wait. + // Fork in service context atomically with the flag to avoid stranded flags and retained caller requests. const armDiffSweep = () => { if (diffSweepPending) return; diffSweepPending = true; @@ -3333,7 +3333,7 @@ export const make = Effect.gen(function* () { if (held > 0) armDiffSweep(); }), ); - const diff: PullRequestService["Service"]["diff"] = (input) => { + const readDiff = (input: PullRequestDiffInput) => { const key = JSON.stringify([ refCacheKey(input), input.cursor ?? null, @@ -3342,22 +3342,24 @@ export const make = Effect.gen(function* () { ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) : null, ]); - const read = Cache.get(diffCache, key).pipe( - Effect.tap((value) => - canCacheDiff(value) - ? Effect.void - : Cache.getSuccess(diffCache, key).pipe( - Effect.flatMap((current) => - Option.isSome(current) && current.value === value - ? Cache.invalidate(diffCache, key) - : Effect.void, - ), - Effect.uninterruptible, - ), + const evictDiffs = Cache.entries(diffCache).pipe( + Effect.flatMap((entries) => + Effect.forEach( + diffCacheEvictions(Array.from(entries, ([key, read]) => [key, read.value] as const)), + (evicted) => Cache.invalidate(diffCache, evicted), + ), ), + Effect.uninterruptible, + ); + const read = Cache.get(diffCache, key).pipe( + Effect.ensuring(evictDiffs), Effect.ensuring(Effect.sync(armDiffSweep)), ); - return staleDiff.serve(key, read); + return { key, read }; + }; + const diff: PullRequestService["Service"]["diff"] = (input) => { + const { key, read } = readDiff(input); + return staleDiff.serve(key, read.pipe(Effect.map((read) => read.value))); }; const filesViewedCache = yield* Cache.makeWith( @@ -3370,8 +3372,7 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), }, ); - // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, - // so a reference keyed as the client spelled it would never see a refresh or a press. + // Key canonical refs to match epochs bumped with the remote's spelling. const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => canonicalRef(input).pipe( Effect.flatMap((ref) => @@ -3423,8 +3424,9 @@ export const make = Effect.gen(function* () { if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); const { result, at } = yield* Cache.get(listStatsCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listStatsCache, key), ), ); for (const [key, ref] of missing) { @@ -3553,10 +3555,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * The file names in each diff slice, keyed as the slice is. Names stay small when a patch is too - * large to cache, so expanding a file in a large change request does not fetch its diff again. - */ + // Cache small filename lists even when patches exceed limits, avoiding diff refetches on file expansion. const diffFiles = (() => { const held = new Map< string, @@ -3570,19 +3569,10 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { - const key = sliceKey(input); - const now = yield* Clock.currentTimeMillis; - const cached = held.get(key); - if (cached !== undefined && now - cached.at <= ttlMs) return cached; - const slice = yield* diff(input); + const recordPaths = (key: string, slice: ProviderDiffFilePaths, at: number) => { const entry = { - at: now, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), - ), + at, + files: new Set(slice.files.map((file) => fileKey(file.oldPath, file.newPath))), nextCursor: slice.nextCursor, }; held.delete(key); @@ -3592,13 +3582,43 @@ export const make = Effect.gen(function* () { } held.set(key, entry); return entry; + }; + const record = (key: string, slice: PullRequestDiffResult, at: number) => + recordPaths( + key, + { + files: parseDiffFilePaths(slice.patch).map((file) => ({ + oldPath: file.oldPath ?? file.newPath ?? "", + newPath: file.newPath ?? file.oldPath ?? "", + })), + nextCursor: slice.nextCursor, + }, + at, + ); + const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { + const key = sliceKey(input); + const now = yield* Clock.currentTimeMillis; + const cached = held.get(key); + if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const project = yield* requireProject(input); + if (project.api.capabilities.diff && project.api.getDiffFilePaths) { + const slice = yield* project.api + .getDiffFilePaths({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diffFileContents"))); + return recordPaths(key, slice, now); + } + const { value, observedAt } = yield* readDiff(input).read; + return record(key, value, observedAt); }); - return { read: credentialCached(read), fileKey }; + return { read: credentialCached(read), record, fileKey }; })(); - /** - * Whether the change request, or the one commit asked for, changes this file under these names. - * Walks the same slices the reader was shown, reading only their file names once held. - */ const changesFile = (input: PullRequestDiffFileContentsInput) => { const wanted = diffFiles.fileKey(input.oldPath, input.newPath); const walk = (cursor: string | undefined): Effect.Effect => @@ -3619,9 +3639,7 @@ export const make = Effect.gen(function* () { ); return walk(undefined); }; - // Only the files the change request itself changes: these are read with the project's own - // credential, and a reader who may look at the change request may not thereby read the rest of - // the repository. + // PR read access does not grant arbitrary repository reads with the project credential; restrict to changed files. const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => changesFile(input).pipe( Effect.flatMap((changed) => @@ -3643,10 +3661,13 @@ export const make = Effect.gen(function* () { withRoutingCredential, list, filterOptions, - listStats: (input) => - Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( - Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), - ), + // The local operation owns the span; this wrapper only canonicalizes refs. + listStats: Effect.fnUntraced(function* (input) { + const refs = yield* Effect.forEach(input.refs, (ref) => + canonicalRef(ref).pipe(Effect.option), + ); + return yield* listStats({ ...input, refs: refs.flatMap(Option.toArray) }); + }), summary: credentialCached(summary), stack: credentialCached(stack), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts index 746e29a506a65..0e9f353900eb3 100644 --- a/apps/server/src/pullRequest/http.ts +++ b/apps/server/src/pullRequest/http.ts @@ -6,7 +6,7 @@ import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/htt import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as PullRequestService from "./PullRequestService.ts"; -/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */ +// Large patches benefit from HTTP compression and flow control. export const layer = HttpApiBuilder.group( EnvironmentHttpApi, "pullRequests", diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index fc2913e3efd43..596808eb46a07 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -46,10 +46,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); - /** - * Spelled from the project rather than the reference, since a reference arrives however - * the client spelled it while the epoch is bumped against the remote's own spelling. - */ + // Epochs use remote spelling; canonicalize client refs before keying. const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) => [ refEpoch({ ...ref, host: project.host, repository: project.repository }), @@ -92,8 +89,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial - // answer would let an old revision ride past the point it should have been re-read. + // Partial answers retain the oldest timestamp so carried revisions cannot outlive their freshness. const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path)) ? at : (carried?.at ?? at); @@ -110,11 +106,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return paths.every((path) => held.asked.has(path)) ? held : null; }; - /** - * `held` answers from a stale value and refetches off the critical path, since a badge a - * moment behind beats a page that won't paint until the host answers; `fresh` is for the - * press itself, which must not store a revision the head already moved off. - */ + // Held reads refresh off the critical path; presses need fresh revisions to avoid saving obsolete heads. const fileRevisionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -124,8 +116,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; if (read === undefined) return Effect.succeed(null); - // Suspended, so a held answer costs the host nothing: a provider is free to do its work as - // the request is built rather than as the effect is run. + // Suspend request construction too; providers may start work while building requests. const fetch = Effect.suspend(() => { const key = fileRevisionsKey(project, ref); return read({ @@ -147,8 +138,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return Effect.succeed(held.revisions); if (freshness === "fresh") return fetch; if (refreshingFileRevisions.has(key)) return Effect.succeed(held.revisions); - // Its own fiber rather than a child: the caller has been answered and is gone before this - // lands. + // Refresh independently; it outlives the answered caller. return Effect.sync(() => { refreshingFileRevisions.add(key); runFork( @@ -174,10 +164,7 @@ export interface Dependencies extends FileRevisionsDependencies { ) => Effect.Effect; } -// A plain factory rather than a `Context.Service` (against the preference in -// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct -// at one instance per service, and a layer provided at two points would give two of each behind -// one epoch counter. +// Use one factory instance per service; multiple layers would duplicate revision state and gates behind one epoch. export const make = (dependencies: Dependencies) => { const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; const { fileRevisionsOf } = makeFileRevisions(dependencies); @@ -237,10 +224,7 @@ export const make = (dependencies: Dependencies) => { }, ); - /** - * A tick's host round trip is slower than an untick's, so unordered presses could finish - * out of order and leave a stale tick standing over a later untick. - */ + // Serialize presses so slower tick lookups cannot overwrite later unticks. const filesViewedGates = new Map< string, { readonly gate: Semaphore.Semaphore; pending: number } @@ -251,9 +235,7 @@ export const make = (dependencies: Dependencies) => { number: number, write: Effect.Effect, ) => - // Suspended rather than generated, so finding the gate, putting it in and taking a place in - // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert - // lets two presses each make a gate of their own and neither wait on the other. + // Atomically find, register and queue on gates; yielding during creation lets concurrent presses use separate gates. Effect.suspend(() => { const key = `${project.project.id} ${project.remote} ${number}`; const held = filesViewedGates.get(key); @@ -294,9 +276,7 @@ export const make = (dependencies: Dependencies) => { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // A path left out of the answer stores with no baseline, not the empty revision, since - // the empty revision is itself an answer and would misreport the file once it turns - // out to have a version after all. + // Missing paths have null baselines; empty revision means confirmed deletion. files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b5f400dc3432f..7f0e86bd6cf49 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -19,6 +19,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; 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 Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -36,6 +37,7 @@ import { } from "../cloud/config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts"; @@ -188,6 +190,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, @@ -204,7 +207,6 @@ const makeTestRelay = Effect.fnUntraced(function* ( getThreadTranscript: unused, getThreadSnapshotWindow: unused, getProjectThreadRecords: () => Effect.die("unused project record read"), - getProjectThread: unused, listProjectThreads: unused, sendToThread: unused, assertProviderInstanceEnabled: unused, @@ -266,21 +268,20 @@ const makeTestRelay = Effect.fnUntraced(function* ( delete: unused, getByWorkspaceRoot: unused, snapshot: Effect.succeed({ projects: [] } as never), + listPage: unused, getShell: unused, listShells: unused, getById: () => - Effect.succeed( - Option.some({ - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/workspace", - defaultModelSelection: null, - scripts: [], - createdAt: NOW, - updatedAt: NOW, - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }), }), Effect.provideService(FetchHttpClient.Fetch, fetch), Effect.provide(NodeCrypto.layer), @@ -392,6 +393,8 @@ describe("AgentAwarenessRelay", () => { const terminal = shell({ status }); yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications[1]?.state?.phase, status); const now = yield* DateTime.now; @@ -419,6 +422,113 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); + it.effect("holds a completion through a continuation gap, then publishes it once", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }), + ); + + it.effect("still publishes a completion when the thread settles during the hold", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("drops a held completion when a queued continuation hides the thread", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "queued" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.notInclude( + publications.map((publication) => publication.state?.phase ?? null), + "completed", + ); + }), + ); + + it.effect("retries a failed completion even after the thread settles", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + respond: (attempt) => + attempt === 2 + ? new Response("unavailable", { status: 503 }) + : Response.json({ ok: true, deliveries: [] }), + }); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state ?? null, null); + }), + ); + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); @@ -595,10 +705,15 @@ describe("AgentAwarenessRelay", () => { yield* deliver( itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), ); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase), ["running", "completed"], ); - }).pipe(Effect.scoped, Effect.provide(ProjectionStore.layerMemory)), + }).pipe( + Effect.scoped, + Effect.provide(ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory))), + ), ); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c4ae14af0b6cf..10ddf29745677 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -105,6 +105,7 @@ function shouldPublishAgentAwarenessEvent( return true; // Unsettling must not re-alert a dismissed result. case "thread.unsettled": + case "thread.guest-share-updated": case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": @@ -148,8 +149,7 @@ export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* ( const enqueue = (threadId: ThreadId) => Effect.suspend(() => { if (queued.has(threadId)) return Effect.void; - // Removing the ID when processing starts allows one new queue entry - // for updates received while the current snapshot is being published. + // Remove queued IDs when processing starts so publication can queue one later update. queued.add(threadId); return worker.enqueue(threadId); }).pipe(Effect.uninterruptible); @@ -290,7 +290,6 @@ function describeThreadShellForAwareness( function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; readonly thread: Option.Option; readonly project: Option.Option; }): { @@ -360,7 +359,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -410,10 +409,10 @@ export const make = Effect.gen(function* () { let cachedRelayClient: Effect.Success> | undefined; let publishedRelayConfig: NonNullable> | undefined; - // The confirming publish is re-enqueued through the same drainable worker as - // every other publish, so a confirmed tombstone can never race an in-flight - // live update; a recovered state clears the deadline. + // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -432,6 +431,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -512,18 +512,14 @@ export const make = Effect.gen(function* () { }); }); - // Per-thread shell read: this publish runs for every activity-relevant - // domain event, so materializing the full shell here would make the cost - // of one thread's activity proportional to how many threads exist. + // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( threadShell != null && isSubagentThread(threadShell) && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) ) { - // Subagents never project activity, so the relay holds no row to clear. - // Their events would otherwise publish a tombstone each, and every - // publish re-delivers the user's aggregate. + // Subagents have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; } const thread = @@ -535,7 +531,6 @@ export const make = Effect.gen(function* () { : Option.none(); const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ environmentId, - threadId, thread, project, }); @@ -548,11 +543,9 @@ export const make = Effect.gen(function* () { if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; } if (publishedStateByThread.get(threadId) === publishIdentity) { - // The projection is back at (or never left) the last published state, so - // any pending deferred confirmation is moot. Leaving the deadline in - // place would let a much later transient null find it already expired - // and publish a tombstone immediately, skipping the deferral window. + // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -561,16 +554,15 @@ export const make = Effect.gen(function* () { return; } - // - null (tombstone) while the previous published state was live: deletes - // the thread from every armed card mid-conversation. - // - completed as the thread's FIRST published state: sessions boot at - // "ready" before their first turn, which projects as completed for an - // instant and sends a spurious Done notification at thread birth. + // Confirm tombstones over live state to avoid removing active cards. + // Confirm completion: a run can end moments before its continuation starts, and the relay + // alerts on every move into completed. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + snapshot.state?.phase === "completed"; if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -595,8 +587,37 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = + snapshot.state === null && + heldCompletions.has(threadId) && + Option.isSome(thread) && + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, + }).pipe(withRelayClientTracing); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -618,6 +639,7 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { @@ -659,8 +681,7 @@ export const make = Effect.gen(function* () { const attempts = publishRetries.get(threadId)?.attempts ?? 0; const delayMs = RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS[attempts]; if (delayMs === undefined) { - // Keep the exhausted budget until fresh activity or a successful publish - // clears it; an old confirmation timer must not start another retry series. + // Retain exhausted budgets until activity or success resets them; old timers must not restart retries. yield* Effect.logWarning("agent activity publish retry budget exhausted", { threadId }); return; } @@ -821,7 +842,9 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, drain: worker.drain, - requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), + requestCatchUp: Effect.fn("AgentAwarenessRelay.requestCatchUp")(function () { + return Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid); + }), start, }); }); diff --git a/apps/server/src/relay/HeldHooksWaker.ts b/apps/server/src/relay/HeldHooksWaker.ts index 5046d62dccfdd..9fee885aa8199 100644 --- a/apps/server/src/relay/HeldHooksWaker.ts +++ b/apps/server/src/relay/HeldHooksWaker.ts @@ -33,8 +33,7 @@ export const layer = Layer.effectDiscard( ), ); yield* runtime.tunnelConnected.pipe( - // cloudflared registers several connections per (re)connect within a - // few seconds; they are one wake. + // Coalesce cloudflared's several connections per reconnect into one wake. Stream.debounce("3 seconds"), Stream.runForEach(() => wake), Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index ddd31817c4ce5..770359b3e9307 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -26,6 +26,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Ndjson from "effect/encoding/Ndjson"; +import { eventVersion as messageVersion } from "./NativeTelemetryClient.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { subscribeBeforeSnapshotWithoutMutex } from "../utils/subscribeBeforeSnapshot.ts"; @@ -257,12 +258,6 @@ function normalizeReceiverError(error: unknown): DesktopTelemetryReceiverError { return new DesktopTelemetryDecodeFailed({ cause: error }); } -function messageVersion(value: unknown): number | undefined { - if (typeof value !== "object" || value === null) return undefined; - const version = Reflect.get(value, "version"); - return typeof version === "number" ? version : undefined; -} - export const writeAllToFileDescriptor = Effect.fn( "resourceTelemetry.desktopTelemetryReceiver.writeAllToFileDescriptor", )(function* (fd: number, payload: Buffer) { @@ -322,7 +317,7 @@ export function requireDesktopTelemetryWriteProgress( : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -458,8 +453,7 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (config.desktopTelemetryFd !== undefined) { const fd = config.desktopTelemetryFd; - // The parent waits for this server before closing its pipe. Socket reads - // can be cancelled; filesystem reads would keep process.exit waiting. + // The parent waits before closing; use cancellable socket reads so filesystem reads cannot block exit. const readable = yield* Effect.acquireRelease( Effect.try({ try: () => new NodeNet.Socket({ fd, readable: true, writable: false }), @@ -638,16 +632,25 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, - requestDesktopUpdate: (requestId) => - sendControlMessage({ - version: 1, - type: "requestDesktopUpdate", - requestId, - }), - commitDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }), - cancelDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( + function (requestId) { + return sendControlMessage({ + version: 1, + type: "requestDesktopUpdate", + requestId, + }); + }, + ), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }); + }, + ), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }); + }, + ), desktopUpdates: Effect.gen(function* () { const subscription = yield* PubSub.subscribe(updateReportChanges); const initial = yield* Ref.get(latestUpdateReport); @@ -695,10 +698,18 @@ export const layerTest = ( changes: Stream.empty, })), ), - setDiagnosticsDemand: () => Effect.void, - requestDesktopUpdate: () => Effect.void, - commitDesktopUpdate: () => Effect.void, - cancelDesktopUpdate: () => Effect.void, + setDiagnosticsDemand: Effect.fn("DesktopTelemetryReceiver.setDiagnosticsDemand")(function () { + return Effect.void; + }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")(function () { + return Effect.void; + }), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")(function () { + return Effect.void; + }), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")(function () { + return Effect.void; + }), desktopUpdates: overrides.desktopUpdates ?? Effect.succeed({ diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 1cc53e1de2f13..4788689046af3 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -32,8 +32,7 @@ function darwinAvailableMemory(output: string): number | null { const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; if (!pageSize || !free || !inactive || !speculative) return null; - // vm_stat subtracts speculative pages from its printed "Pages free" count. - // Adding them here counts each reclaimable page once; purgeable pages overlap. + // vm_stat excludes speculative from free; add it once, without overlapping purgeable pages. const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; } diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 6f4a019a8ac78..0d9e442c4546a 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -326,7 +326,7 @@ const isProtocolMismatch = Schema.is(NativeTelemetryProtocolMismatch); const isDecodeFailed = Schema.is(NativeTelemetryDecodeFailed); const isCommandFailed = Schema.is(NativeTelemetryCommandFailed); -function eventVersion(value: unknown): number | undefined { +export function eventVersion(value: unknown): number | undefined { if (typeof value !== "object" || value === null) return undefined; const version = Reflect.get(value, "version"); return typeof version === "number" ? version : undefined; @@ -361,7 +361,7 @@ function canCommandNativeTelemetrySidecar( return hasHandle && (status === "healthy" || status === "degraded"); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1088,14 +1088,19 @@ export const layerTest = ( processTree: true, }), snapshots: Stream.empty, - readHistory: () => - Effect.fail( + readHistory: Effect.fn("NativeTelemetryClient.readHistory")(function () { + return Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor history was configured for this test.", }), - ), - setExternalProcesses: () => Effect.void, - setHostPowerState: () => Effect.void, + ); + }), + setExternalProcesses: Effect.fn("NativeTelemetryClient.setExternalProcesses")(function () { + return Effect.void; + }), + setHostPowerState: Effect.fn("NativeTelemetryClient.setHostPowerState")(function () { + return Effect.void; + }), sampleNow: Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor sample was configured for this test.", diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts index 694803b648420..f10937c4ebd43 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts @@ -1,7 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; -import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import type { ResourceTelemetryProcess, ResourceTelemetrySnapshot } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; -import { groupProcesses } from "./ProcessGroupMetrics.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProcessGroupMetrics from "./ProcessGroupMetrics.ts"; +import * as ResourceTelemetry from "./ResourceTelemetry.ts"; + +const { groupProcesses } = ProcessGroupMetrics; const process = ( pid: number, @@ -57,3 +67,54 @@ describe("groupProcesses", () => { }); }); }); + +describe("ProcessGroupMetrics.layer", () => { + it.effect("logs the first failed sample and the recovery, not every repeat", () => { + const logs: Array = []; + const logger = Logger.make(({ logLevel, message }) => { + logs.push(`${logLevel.toUpperCase()} ${String([message].flat()[0])}`); + }); + return Effect.gen(function* () { + const outcomes = yield* Ref.make>(["fail", "fail", "ok"]); + const refresh = Ref.modify(outcomes, ([next = "ok", ...rest]) => [next, rest] as const).pipe( + Effect.flatMap((next) => + next === "fail" + ? Effect.fail( + new ResourceTelemetry.ResourceTelemetryRefreshFailed({ + operation: "refresh", + cause: new Error("Resource monitor binary was not found for darwin/arm64."), + }), + ) + : Effect.succeed({ processes: [] } as unknown as ResourceTelemetrySnapshot), + ), + ); + const telemetry = { refresh } as unknown as ResourceTelemetry.ResourceTelemetry["Service"]; + + yield* Layer.build( + ProcessGroupMetrics.layer.pipe( + Layer.provide(Layer.succeed(ResourceTelemetry.ResourceTelemetry, telemetry)), + Layer.provide( + ServerConfig.layer({ + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + } as ServerConfig.ServerConfig["Service"]), + ), + ), + ); + yield* TestClock.adjust("3 minutes"); + + assert.deepStrictEqual(logs, [ + "WARN process group metrics are not exported: sample failed", + "DEBUG process group metrics sample failed", + "INFO process group metrics sample recovered", + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); +}); diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts index 6f8f29c37161f..b3a02e94e244a 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts @@ -1,7 +1,9 @@ import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; +import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; import { metricAttributes } from "../observability/Metrics.ts"; @@ -113,10 +115,28 @@ export const layer = Layer.effectDiscard( yield* Metric.update(Metric.withAttributes(processCount, attributes), total.count); } }); - yield* sample().pipe( - Effect.catch((error) => - Effect.logDebug("process group metrics sample failed", { error: error.message }), + // A failing collector would otherwise leave these series silently absent, so the first + // failure and the recovery are logged; the repeats stay at debug. + const failing = yield* Ref.make(false); + const recordSuccess = Ref.getAndSet(failing, false).pipe( + Effect.flatMap((wasFailing) => + wasFailing ? Effect.logInfo("process group metrics sample recovered") : Effect.void, ), + ); + const recordFailure = (error: ResourceTelemetry.ResourceTelemetryRefreshFailed) => { + const cause = Cause.fail(error); + return Ref.getAndSet(failing, true).pipe( + Effect.flatMap((wasFailing) => + wasFailing + ? Effect.logDebug("process group metrics sample failed", cause) + : Effect.logWarning("process group metrics are not exported: sample failed", cause), + ), + Effect.annotateLogs({ operation: error.operation }), + ); + }; + yield* sample().pipe( + Effect.andThen(recordSuccess), + Effect.catch(recordFailure), Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)), Effect.forever, Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 6eb0a5fd28610..232d89b7aa50c 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -8,8 +8,7 @@ import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; -// The override checks POSIX exec bits on a real file under a linux platform -// mock; NTFS never reports those bits, so the check cannot be satisfied there. +// NTFS lacks POSIX exec bits required by this Linux-mocked override check. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; describe("ResourceMonitorBinary", () => { diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index d582698d9ce30..f249197d8510a 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -189,8 +189,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); if (Option.isSome(stat)) { - // Directories are executable-looking on win32, where the exec-bit test - // below is skipped, and only fail later at spawn. + // Windows skips exec-bit checks, so directories would otherwise pass until spawn. const unusable = stat.value.type === "Directory" || (platform !== "win32" && (stat.value.mode & 0o111) === 0); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 2af6d98da0d19..99d67fa661748 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -4,7 +4,6 @@ import type { ResourceMonitorSnapshotEvent, ResourceTelemetryHealth, ResourceTelemetryHistoryInput, - ResourceTelemetryProcessIdentity, ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "@t3tools/contracts"; @@ -67,9 +66,6 @@ export class ResourceTelemetry extends Context.Service< input: ResourceTelemetryHistoryInput, ) => Effect.Effect; readonly refresh: Effect.Effect; - readonly validateProcessIdentity: ( - identity: ResourceTelemetryProcessIdentity, - ) => Effect.Effect; readonly retry: Effect.Effect; } >()("t3/resourceTelemetry/ResourceTelemetry") {} @@ -142,7 +138,7 @@ function buildHealth(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; @@ -465,31 +461,12 @@ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(functi ), ); - const validateProcessIdentity: ResourceTelemetry["Service"]["validateProcessIdentity"] = ( - identity, - ) => - nativeClient.sampleNow.pipe( - Effect.map(({ snapshot }) => - snapshot.processes.some( - (process) => process.pid === identity.pid && process.startTimeMs === identity.startTimeMs, - ), - ), - Effect.mapError( - (cause) => - new ResourceTelemetryRefreshFailed({ - operation: "validateProcessIdentity", - cause, - }), - ), - ); - return ResourceTelemetry.of({ latest, changes: liveChanges, subscribe, readHistory, refresh, - validateProcessIdentity, retry: nativeClient.retry.pipe( Effect.zip(Ref.get(state)), Effect.map(([accepted, current]): ResourceTelemetryRetryResult => ({ diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index a9fb05ab14395..4df56454cfc58 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -23,7 +23,6 @@ function layer(input: { readonly previousWorktreesDirectories?: ReadonlyArray; readonly projectRoots?: ReadonlyArray | "unavailable"; readonly threadWorktrees?: ReadonlyArray<{ worktreePath: string; deleted?: boolean }>; - /** What `git worktree list` reports for every project. */ readonly gitWorktrees?: ReadonlyArray; }) { const projectRoots = input.projectRoots ?? []; @@ -175,7 +174,6 @@ describe("ReviewService", () => { assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot); assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild); - // A child whose name starts with `..` is still inside. const dottedChild = `${projectRoot}/..feature`; yield* fs.makeDirectory(dottedChild); assert.strictEqual((yield* review(dottedChild, [projectRoot])).cwd, dottedChild); @@ -196,11 +194,9 @@ describe("ReviewService", () => { const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const project = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" }); - // Checkouts outside every managed folder, as `git worktree add ../feature` makes. const bound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-bound-" }); const deleted = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-deleted-" }); const unbound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-unbound-" }); - // A client can set a thread's path to any folder; git doesn't list this one. const claimed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-claimed-" }); yield* fs.makeDirectory(`${bound}/sub`); @@ -219,9 +215,7 @@ describe("ReviewService", () => { { worktreePath: bound }, { worktreePath: claimed }, { worktreePath: deleted, deleted: true }, - // A relative path would resolve against the server's own cwd. { worktreePath: "t3-review-relative" }, - // A filesystem root would make every folder look bound. { worktreePath: "/" }, ], }), diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 488cc84be0a70..8f88b387bd454 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -32,7 +32,7 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -99,19 +99,7 @@ export const make = Effect.gen(function* () { }; } - const getDriverDiffPreview = handle.driver.getDiffPreview; - if (!getDriverDiffPreview) { - if (handle.kind === "git") { - return yield* git.getReviewDiffPreview(input); - } - return yield* new VcsUnsupportedOperationError({ - operation: "ReviewService.getDiffPreview", - kind: handle.kind, - detail: `The ${handle.kind} VCS driver does not support review diff previews.`, - }); - } - - return yield* getDriverDiffPreview(input); + return yield* git.getReviewDiffPreview(input); }); const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn( diff --git a/apps/server/src/rpcInitialItems.ts b/apps/server/src/rpcInitialItems.ts index 172a3a897a32a..6f3409858920f 100644 --- a/apps/server/src/rpcInitialItems.ts +++ b/apps/server/src/rpcInitialItems.ts @@ -6,8 +6,7 @@ export function rpcInitialItems
    (items: ReadonlyArray): Stream.Stream { return Stream.unwrap( Effect.sync(() => { const initial = pending; - // concat keeps its initial stream definition alive during the live tail. - // That definition must no longer own delivered snapshots or replay rows. + // concat retains the initial stream during live tailing; release delivered snapshots and rows. pending = undefined; return initial === undefined ? Stream.empty : Stream.fromIterable(initial); }), diff --git a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts index abf7c148acc78..0f33869c387ea 100644 --- a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts +++ b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts @@ -1,9 +1,4 @@ -/** - * The relay's delivery id, receive time and trace context arrive as plain - * headers, and the webhook URL can also be called directly, so they are only - * trusted alongside a proof the relay signed with its mint key. A request - * without a valid proof is handled as a direct request. - */ +// Trust relay headers only with a mint-key-signed proof; otherwise handle the webhook as a direct request. import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay"; import { normalizeRelayIssuer, diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index 080ed8f7032e7..f13a7bd1eca39 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -47,8 +47,6 @@ export function nextScheduledRunAt( } if (schedule.type === "webhook") return null; if (schedule.type === "interval") { - // Persisted rows created before the one-minute floor remain readable, but - // they must not retain their old high-frequency execution rate. // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); } @@ -97,9 +95,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul } if (a.type === "webhook") return b.type === "webhook"; if (b.type !== "fixed_time") return false; - // The contract accepts padded and unpadded hours ("9:00" and "09:00"), so - // compare the parsed time — string equality would treat a format-only edit - // as a schedule change and recompute the pending run. + // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); const bTime = parseTimeOfDay(b.timeOfDay); return ( @@ -121,25 +117,3 @@ export function isMissedFixedTimeRun( if (schedule.type !== "fixed_time") return false; return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS; } - -function describeSchedule(schedule: ScheduledTaskSchedule): string { - if (schedule.type === "cron") return `Cron ${schedule.expression} (${schedule.timezone})`; - if (schedule.type === "once") return `Once at ${schedule.runAt}`; - if (schedule.type === "webhook") return "On webhook"; - if (schedule.type === "interval") { - const minutes = schedule.everyMs / MINUTE_MS; - if (Number.isInteger(minutes)) { - return `Every ${minutes === 1 ? "minute" : `${minutes} minutes`}`; - } - return `Every ${Math.round(schedule.everyMs / 1000)} seconds`; - } - - const weekdayCount = schedule.weekdays?.length ?? 0; - const days = - weekdayCount === 0 - ? "day" - : weekdayCount === 5 && schedule.weekdays?.every((day) => day >= 1 && day <= 5) - ? "weekday" - : "selected day"; - return `At ${schedule.timeOfDay} every ${days}`; -} diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.host.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.host.test.ts index e9fbbdd18ab9d..a77e34c8fb406 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.host.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.host.test.ts @@ -30,34 +30,33 @@ const environmentLayer = ( onSend: (input: ThreadManagementService.ThreadManagementSendInput) => Effect.Effect = () => Effect.void, ) => - ScheduledTaskService.layer - .pipe( - Layer.provide( - Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ - namedProjectsRoot: "/projects", - isScratchProject: () => Effect.succeed(false), - }), - ScheduledTaskTestkit.layerLocalProjects( - (id) => hosted !== null && (id === hosted.projectId || id === otherProject), - ), - Layer.mock(ThreadManagementService.ThreadManagementService)({ - getThreadShell: (threadId) => - Effect.succeed( - threadId === hosted?.threadId - ? ({ id: threadId, projectId: hosted.projectId, archivedAt: null } as never) - : null, - ), - sendToThread: (input) => onSend(input).pipe(Effect.as({} as never)), - }), + ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + ScheduledTaskTestkit.layerLocalProjects( + (id) => hosted !== null && (id === hosted.projectId || id === otherProject), ), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed( + threadId === hosted?.threadId + ? ({ id: threadId, projectId: hosted.projectId, archivedAt: null } as never) + : null, + ), + sendToThread: (input) => onSend(input).pipe(Effect.as({} as never)), + }), ), - ) - .pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); + ), + Layer.provideMerge(SqlitePersistence.layerMemory), + ); const nightlyInput = (overrides: Record = {}) => decodeUpsertInput({ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 706008582475c..04567d7e26eea 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -180,8 +180,6 @@ it.effect( yield* insertRow(sql, { ...row, enabled: 1 }, now); } - // it.effect freezes the clock; pin it just past the seeded due time so - // the poll sees the rows as due. yield* TestClock.setTime(Date.parse(now) + 1_000); const dispatched = yield* Ref.make(0); const lastDispatched = yield* Deferred.make(); @@ -217,8 +215,7 @@ it.effect( ), ), ); - // The poll loop delays before its first pass, so the frozen test - // clock must advance once to trigger it. + // The poll delays before its first pass; advance the frozen clock once. yield* TestClock.adjust("6 seconds"); yield* Deferred.await(lastDispatched); const inflight = yield* sql<{ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index fad9bb9bc1eb3..e565df8af531f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -92,7 +92,7 @@ const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; -/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ +// Enforce the relay's per-task/minute limit here too; tunnel hostnames are public. const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ "accepted", @@ -288,11 +288,7 @@ export class ScheduledTaskService extends Context.Service< readonly getWebhookDelivery: ( input: ScheduledTaskGetWebhookDeliveryInput, ) => Effect.Effect; - /** - * Verifies, logs and dispatches one webhook request. Returns as soon as the - * delivery is logged; the run itself continues in the background so senders - * with short timeouts get their answer immediately. - */ + /** Verifies, logs and dispatches; returns after logging while the run continues in the background. */ readonly triggerWebhook: ( request: WebhookTriggerRequest, ) => Effect.Effect; @@ -320,8 +316,7 @@ function nextRunAt( from: DateTime.DateTime, ): string | null { if (!task.enabled) return null; - // A stored interval can decode yet overflow the representable DateTime - // range; an unrepresentable occurrence means the task has no next run. + // Decoded intervals can overflow DateTime; unrepresentable occurrences have no next run. try { const next = nextScheduledRunAt(task.schedule, from); return next !== null && Number.isFinite(DateTime.toEpochMillis(next)) ? iso(next) : null; @@ -412,9 +407,7 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct const decoded = yield* Effect.result(decodeRow(row)); if (Result.isSuccess(decoded)) { const task = decoded.success; - // next_run_at is a freeform string at the schema level; a stored value - // that cannot parse as a DateTime would defect the poll below, so the - // row is skipped here like any other corrupt row. + // next_run_at is freeform; skip unparseable values before they defect the poll. if (task.nextRunAt === null || Option.isSome(DateTime.make(task.nextRunAt))) { tasks.push(task); } else { @@ -448,8 +441,7 @@ export const layer = Layer.effect( const webhookPermits = yield* Ref.make>( new Map(), ); - // Keyed by task id and creation time, so deliveries of a deleted task that - // finish late release their own count, never a recreated task's. + // Include creation time so late deliveries cannot decrement a recreated task's count. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); const webhookRejectedRateWindows = yield* Ref.make>( @@ -575,11 +567,8 @@ export const layer = Layer.effect( return task; }); - // Run-state columns (last_run_*, run_count) are intentionally absent from - // the conflict clause: they are owned by the run transitions below, and a - // concurrent settings save must not overwrite an in-flight increment. - // Check existence in the write itself so an edit cannot undo a deletion - // that landed after upsert loaded the previous task. + // Run transitions own run-state columns; settings saves must not overwrite them. + // Check existence in the write so edits cannot undo concurrent deletion. const saveTask = ( task: ScheduledTask, requireExisting: boolean, @@ -737,12 +726,9 @@ export const layer = Layer.effect( ), ); - // Best-effort escape hatch: if anything fails between markRunning and - // markCompleted, write a full terminal record so runDueTasks neither skips - // the task forever (it filters out 'running' rows) nor re-fires it - // immediately: the dispatch may already have gone out, so next_run_at must - // advance. Pending webhook attempts count only with their completion state; - // other attempts count here. + // A failure between markRunning and markCompleted must still write a terminal record: + // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, + // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; @@ -885,9 +871,7 @@ export const layer = Layer.effect( const startedAt = yield* localNow; const startedAtIso = iso(startedAt); - // The in-memory snapshot may be stale: re-read before touching run - // state. The task may have been deleted, paused, or postponed since - // the poll loaded it — none of those may fire. + // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { if (webhook !== undefined) { @@ -917,9 +901,7 @@ export const layer = Layer.effect( ) { return { task: active, threadId: null }; } - // A queued delivery must not run a task that was paused, deleted and - // recreated under the same id, or switched to another trigger while - // it waited for its turn. + // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { const reason = active.createdAt !== task.createdAt @@ -1155,8 +1137,7 @@ export const layer = Layer.effect( const subscribeList: ScheduledTaskService["Service"]["subscribeList"] = () => Stream.unwrap( Effect.gen(function* () { - // Subscribe before taking the snapshot so a change landing between - // the two is buffered by the subscription rather than dropped. + // Subscribe before reading to buffer concurrent changes. const subscription = yield* PubSub.subscribe(changesPubSub); return Stream.concat( Stream.fromEffect(list()), @@ -1301,7 +1282,7 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => replay - ? Effect.succeed(undefined) + ? Effect.void : Effect.fail(taskError(error.message, { taskId: id })), ), ); @@ -1602,8 +1583,7 @@ export const layer = Layer.effect( return sql .withTransaction( Effect.gen(function* () { - // Conditional on the task existing, so a delivery racing a delete - // cannot leave rows that a recreated task with the same id would show. + // Require the task to exist so deletes cannot leave deliveries for a recreated task. yield* sql` INSERT INTO scheduled_task_webhook_deliveries ( delivery_id, task_id, received_at, method, query, headers_json, body, @@ -1963,11 +1943,7 @@ export const layer = Layer.effect( DELETE FROM scheduled_task_webhook_relay_deliveries WHERE seen_at < ${iso(DateTime.subtract(now, { hours: 48 }))} `.pipe(Effect.ignore); - // Claimed in its own table, kept longer than the relay holds a request, - // because the delivery log is trimmed. A rate-limited delivery stays - // held on the relay, so it must give up its claim or the next pass - // would treat it as already delivered. A refusal the sender may retry - // also gives up the caller key's claim (includeKey). + // Claims outlive delivery logs. Retryable refusals release relay and caller-key claims. let claimAcquired = false; const releaseClaim = (result: A, options?: { readonly includeKey: boolean }) => { const ids = [ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 3b992476e776e..25a9c8a4e30af 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -309,7 +309,6 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => assert.equal(delivery.body, new TextDecoder().decode(pullRequestBody)); assert.equal(delivery.renderedPrompt, launched.initialMessage?.text); - // Each delivery gets its own frame, so a request can't predict the tag that closes it. yield* service.triggerWebhook(requestFor(task)); const next = framed.exec((yield* Queue.take(launches)).initialMessage?.text ?? "")?.[1]; assert.isDefined(next); @@ -513,6 +512,20 @@ it.effect.each(queuedDeliveryCases)( ({ reason, apply }) => Effect.gen(function* () { const gate = yield* Deferred.make(); + const deliveryReleased = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === "ScheduledTaskService.releaseFailedDelivery") { + Deferred.doneUnsafe(deliveryReleased, Effect.void); + } + }; + return span; + }, + }); yield* withService( ({ service, launches }) => Effect.gen(function* () { @@ -523,23 +536,17 @@ it.effect.each(queuedDeliveryCases)( yield* apply(service, task.id); yield* Deferred.succeed(gate, undefined); const deliveryId = queued._tag === "accepted" ? queued.deliveryId : undefined; - let delivery = (yield* service.getWebhookDelivery({ + yield* Deferred.await(deliveryReleased); + const delivery = (yield* service.getWebhookDelivery({ id: task.id, deliveryId: deliveryId!, })).delivery; - while (delivery.outcome === "accepted") { - yield* Effect.yieldNow; - delivery = (yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveryId!, - })).delivery; - } assert.equal(delivery.outcome, "dispatch_failed"); assert.equal(delivery.error, reason); assert.equal(yield* Queue.size(launches), 0); }), { gate }, - ); + ).pipe(Effect.withTracer(tracer)); }), ); diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index 8048742a07ff7..23012195a17bb 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -17,7 +17,7 @@ import * as ScheduledTaskService from "./ScheduledTaskService.ts"; /** The relay enforces the same cap. */ export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; -/** Response header naming what happened to the request; the relay records it. */ +// The relay records this outcome header. const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome"; const json = (status: number, body: Record, outcome: string) => diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index 6aa23e12b0363..eaee34f52da39 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -54,7 +54,6 @@ describe("renderWebhookPrompt", () => { { ...githubPullRequest, bodyText: JSON.stringify({ text }) }, "id", ); - // The adapters' pattern finds only the template's own mention. const mentions = [...rendered.prompt.matchAll(SKILL_MENTION_PATTERN)].map((match) => match[2]); assert.deepEqual(mentions, ["review"]); // Codex and Pi read `$name` after any character, digits included. diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 84e2599dc0d75..fc43dbff921ed 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -1,14 +1,5 @@ -/** - * Anyone who can call the URL chooses these values, so each one is wrapped in - * `` tags and a closing note marks the tagged text as - * untrusted request data. The caller picks a fresh random ID per request, so a - * request can't close its own frame. Mentions inside a value are broken so - * they never act; the template's own mentions still do. - * - * Credential-looking headers and query parameters are redacted wherever the - * whole set renders (`{{request}}`, `{{headers}}`, `{{query}}`), as in the - * delivery log. Naming one (`{{headers.authorization}}`) gives its raw value. - */ +// Frame untrusted values with unpredictable per-request tags and break their mentions, preserving template mentions. +// Whole-set placeholders redact credentials; named placeholders return raw values. export interface WebhookRequest { readonly method: string; @@ -56,17 +47,8 @@ export function redactQuery(query: string): string { // Trim after matching so unclosed placeholders cannot backtrack over whitespace partitions. const PLACEHOLDER = /\{\{([^{}]*)\}\}/g; -/** - * Syntax an adapter or provider CLI may act on before the model reads the - * prompt: `$name` skill mentions (any currency sign, after any character in - * Codex and Pi), `@` file, agent and plugin mentions (after punctuation or - * letters in some CLIs), a `/name` command word mid-text, and `t3-context:` - * links. A word joiner goes after every currency sign and `@`, after a `/` - * that starts a single-segment word, and inside the `t3-context` scheme and - * any `webhook-data` name, so a value can't forge a frame tag. - * Checked against Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh - * 0.2.0-rc.2; other agents may parse more. - */ +// Break mentions before provider CLI parsing, including currency signs, embedded @, commands, context links and frame tags. +// Checked with Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh 0.2.0-rc.2; others may parse more. const MENTION = /[\p{Sc}@]|(?<=^|\s)\/(?=[^\s/]+(?:\s|$))|t3-context(?=:)|webhook(?=-data)/giu; function parseBody(request: WebhookRequest): unknown { @@ -74,8 +56,7 @@ function parseBody(request: WebhookRequest): unknown { if (contentType.includes("application/x-www-form-urlencoded")) { return Object.fromEntries(new URLSearchParams(request.bodyText)); } - // Senders are inconsistent about content types, so any body that parses - // as JSON is addressable. + // Senders mislabel content types; accept any parseable JSON body. try { return JSON.parse(request.bodyText) as unknown; } catch { @@ -183,7 +164,6 @@ export function renderWebhookPrompt( if (!missing.includes(expression)) missing.push(expression); return ""; } - // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; if (raw) return text; framed = true; diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index ffb8cd314d873..8962e5d4103d0 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -1,18 +1,23 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { ProjectId, type OrchestrationV2ServerCommand, + SecretRef, ThreadId, TurnItemId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Tracer from "effect/Tracer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -30,6 +35,8 @@ const withService = ( }) => Effect.Effect, options: { readonly threadId?: ThreadId; + readonly delivery?: "file"; + readonly directory?: string; readonly runStatus?: string; readonly removeFails?: boolean; readonly closedFirst?: boolean; @@ -48,6 +55,7 @@ const withService = ( Layer.succeed( ServerSecretStore.ServerSecretStore, ServerSecretStore.ServerSecretStore.of({ + ...(options.directory === undefined ? {} : { directory: options.directory }), get: (name) => Effect.yieldNow.pipe(Effect.as(Option.fromNullishOr(stored.get(name)))), set: (name, value) => Effect.sync(() => void stored.set(name, value)), create: (name, value) => @@ -98,6 +106,7 @@ const withService = ( type: "secret_request", label: "GitHub token", reason: "Used as GH_TOKEN.", + ...(options.delivery === undefined ? {} : { delivery: options.delivery }), secretStatus, }, ], @@ -163,10 +172,9 @@ it.effect("two concurrent uses of one ref hand the value out once", () => answer: { type: "save", secret: "ghp_secret" }, }); const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId })); - const results = yield* Effect.all( - [service.consume({ ref, projectId }), service.consume({ ref, projectId })].map( - Effect.result, - ), + const results = yield* Effect.forEach( + [service.consume({ ref, projectId }), service.consume({ ref, projectId })], + Effect.result, { concurrency: "unbounded" }, ); assert.deepEqual(results.map((result) => result._tag).toSorted(), ["Failure", "Success"]); @@ -338,3 +346,113 @@ it.effect("a used value that cannot be deleted is not handed out", () => { removeFails: true }, ), ); + +const SECRET_FILE_TTL_MS = 24 * 60 * 60 * 1000; + +it.effect("a file request moves its value to an owner-only file, once", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + const file = yield* withService( + ({ service, stored, dispatched }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token'$(id)" }, + }); + assert.include(dispatched[0] as object, { secretStatus: "saved", delivery: "file" }); + const file = yield* service.saveToFile({ threadId, turnItemId }); + assert.equal(yield* fs.readFileString(file), "cf_token'$(id)"); + assert.deepEqual(valuesOf(stored), []); + if ((yield* HostProcess.Platform) !== "win32") { + assert.equal((yield* fs.stat(file)).mode & 0o777, 0o600); + assert.equal((yield* fs.stat(path.dirname(file))).mode & 0o777, 0o700); + } + // A retried request_secret gets the same file; the value is never a ref again. + assert.equal(yield* service.saveToFile({ threadId, turnItemId }), file); + const ref = SecretRef.make(`secret-ref:${path.basename(file)}`); + const reused = yield* service.consume({ ref, projectId }).pipe(Effect.flip); + assert.equal(reused.reason, "ref_unavailable"); + return file; + }), + { delivery: "file", directory }, + ); + + const { mtime } = yield* fs.stat(file); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS); + yield* withService(() => Effect.void, { directory }); + assert.isTrue(yield* fs.exists(file)); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS + 1); + yield* withService(() => Effect.void, { directory }); + assert.isFalse(yield* fs.exists(file)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a failed file write keeps the value, so the request can retry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + yield* withService( + ({ service }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token" }, + }); + const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId })); + const name = ref.slice("secret-ref:".length); + // A non-empty directory where the partial file goes makes the write fail. + const partial = path.join(directory, "files", `${name}.partial`); + yield* fs.makeDirectory(path.join(partial, "block"), { recursive: true }); + const failed = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(failed.reason, "store_failed"); + yield* fs.remove(partial, { recursive: true }); + const file = yield* service.saveToFile({ threadId, turnItemId }); + assert.equal(yield* fs.readFileString(file), "cf_token"); + }), + { delivery: "file", directory }, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a file is not delivered while its stored copy cannot be removed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + yield* withService( + ({ service }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token" }, + }); + const first = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(first.reason, "consume_failed"); + // A retry finds the file but still will not hand it out until the removal works. + const retried = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(retried.reason, "consume_failed"); + }), + { delivery: "file", directory, removeFails: true }, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a secret the card promised to keep from the agent is never written to a file", () => + withService(({ service, stored }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "ghp_secret" }, + }); + const refused = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(refused.reason, "not_a_file_request"); + assert.isTrue(valuesOf(stored).some((value) => value.includes("ghp_secret"))); + }), + ), +); diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 4470c257f67b5..9e3a7c033c220 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,9 +1,7 @@ -/** - * The user's answer goes straight to the server's secret store under a - * one-use SecretRef; orchestration only records the request and its status. - * A tool that needs the value takes the ref and consumes it, so the value - * never reaches the transcript, projections, clients, or model context. - */ +// Answers stay in the secret store behind one-use refs; orchestration records only request status. +// Consuming tools keep values out of transcripts, projections, clients and model context. +// A `file` request instead moves its value to an owner-only file for the agent's own shell. +import { pipe } from "effect/Function"; import { CommandId, SecretRef, @@ -21,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -34,6 +33,7 @@ const storeName = (ref: SecretRef) => `secret-request-${ref.slice(SECRET_REF_PRE const REF_PATTERN = /^secret-ref:[0-9a-f]{32}$/; const SECRET_REF_TTL_MS = 24 * 60 * 60 * 1000; const STORE_NAME_PATTERN = /^(secret-request-[0-9a-f]{32})\.bin$/; +const FILE_NAME_PATTERN = /^[0-9a-f]{32}(\.partial)?$/; const refFor = (salt: string, threadId: ThreadId, turnItemId: string) => SecretRef.make( @@ -61,13 +61,21 @@ export class SecretRequests extends Context.Service< readonly ref: SecretRef; readonly projectId: ProjectId; }) => Effect.Effect; + /** Moves a saved `file` request's value to an owner-only file and returns its path. */ + readonly saveToFile: (input: { + readonly threadId: ThreadId; + readonly turnItemId: string; + }) => Effect.Effect; } >()("t3/secrets/SecretRequests") {} const make = Effect.gen(function* () { const store = yield* ServerSecretStore.ServerSecretStore; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService.ThreadManagementService; + // Beside the store's own files so the same owner-only directory and cleanup rules apply. + const filesDir = store.directory === undefined ? undefined : path.join(store.directory, "files"); const salt = Buffer.from( yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie), @@ -96,10 +104,8 @@ const make = Effect.gen(function* () { if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) { return yield* new SecretRequestError({ reason: "agent_stopped" }); } - // Store first: the card only says saved once the value is kept. Create, - // not set: a second answer racing this one must not replace the value. - // A value already there on a card still pending is an earlier save whose - // record failed and could not be cleaned up; recording it finishes that save. + // Create before recording so racing answers cannot overwrite values or show saved before storage. + // Existing values on pending cards finish earlier saves whose recording failed. if (input.answer.type === "save") { const encoded = yield* encodeStored({ projectId: records.thread.projectId, @@ -130,22 +136,20 @@ const make = Effect.gen(function* () { label: item.label, reason: item.reason, ...(item.placeholder === undefined ? {} : { placeholder: item.placeholder }), + ...(item.delivery === undefined ? {} : { delivery: item.delivery }), secretStatus, }) .pipe( Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })), - // The card still says pending, so the user can save again; the value - // stored above would make that retry look already answered. + // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" - ? removeLogged(storeName(refFor(salt, input.threadId, item.id))) + ? pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged) : Effect.void, ), ); if (input.answer.type !== "save") return; - // A request is answered once: if the agent's wait closed the card between - // the checks above and this record, the record changed nothing. Nobody - // will receive the ref, so the value is deleted rather than left to expire. + // The wait may have closed the card; delete unrecorded answers because no one receives their refs. const recorded = yield* threadManagement .getThreadRecords(input.threadId, ["turnItems"], { turnItemTypes: ["secret_request"], @@ -156,7 +160,7 @@ const make = Effect.gen(function* () { ); const card = recorded.turnItems.find((candidate) => candidate.id === item.id); if (card?.type === "secret_request" && card.secretStatus !== "saved") { - yield* removeLogged(storeName(refFor(salt, input.threadId, item.id))); + yield* pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged); return yield* new SecretRequestError({ reason: "agent_stopped" }); } }, @@ -180,8 +184,7 @@ const make = Effect.gen(function* () { ), ); - // get and remove are separate store calls; one consumer at a time keeps two - // concurrent calls from both reading a ref before either deletes it. + // Serialize get/remove so concurrent consumers cannot read the same one-use ref. const consumeLock = yield* Semaphore.make(1); const consume: SecretRequests["Service"]["consume"] = (input) => consumeRef(input).pipe( @@ -193,7 +196,8 @@ const make = Effect.gen(function* () { Effect.withSpan("SecretRequests.consume"), ); - const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + /** A live ref's value, left in the store. */ + const readRef = Effect.fn("SecretRequests.readRef")(function* (input: { readonly ref: SecretRef; readonly projectId: ProjectId; }) { @@ -212,12 +216,72 @@ const make = Effect.gen(function* () { yield* removeLogged(storeName(input.ref)); return yield* new SecretRequestError({ reason: "ref_expired" }); } + return decoded.value.value; + }); + + const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + readonly ref: SecretRef; + readonly projectId: ProjectId; + }) { + const value = yield* readRef(input); // One use: the value moves into whatever consumed it. If it cannot be // deleted, it is not handed out, so a ref is never used twice. if (!(yield* removeLogged(storeName(input.ref)))) { return yield* new SecretRequestError({ reason: "consume_failed" }); } - return decoded.value.value; + return value; + }); + + const saveToFile: SecretRequests["Service"]["saveToFile"] = Effect.fn( + "SecretRequests.saveToFile", + )(function* (input) { + const records = yield* threadManagement + .getThreadRecords(input.threadId, ["turnItems"], { + turnItemTypes: ["secret_request"], + messageRoles: [], + }) + .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause }))); + const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId); + // Only a card that told the user the agent's commands can read the value may write it out. + if ( + item?.type !== "secret_request" || + item.secretStatus !== "saved" || + item.delivery !== "file" + ) { + return yield* new SecretRequestError({ reason: "not_a_file_request" }); + } + if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" }); + const ref = refFor(salt, input.threadId, item.id); + const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length)); + return yield* Effect.gen(function* () { + // One use: the file counts as delivered only once the stored copy is gone. A retry with the + // same clientRequestId gets the file the first call wrote and finishes that removal. + const removeStored = removeLogged(storeName(ref)).pipe( + Effect.flatMap((removed) => + removed ? Effect.succeed(file) : new SecretRequestError({ reason: "consume_failed" }), + ), + ); + if (yield* fileSystem.exists(file).pipe(Effect.orElseSucceed(() => false))) { + return yield* removeStored; + } + // The stored value goes only after the file is complete, so a failed write can retry. + const value = yield* readRef({ ref, projectId: records.thread.projectId }); + const partial = `${file}.partial`; + yield* fileSystem.makeDirectory(filesDir, { recursive: true, mode: 0o700 }).pipe( + Effect.andThen(fileSystem.chmod(filesDir, 0o700)), + Effect.andThen(fileSystem.remove(partial, { force: true })), + // `wx` never follows or replaces an existing path. + Effect.andThen( + fileSystem.writeFile(partial, new TextEncoder().encode(value), { + flag: "wx", + mode: 0o600, + }), + ), + Effect.andThen(fileSystem.rename(partial, file)), + Effect.mapError((cause) => new SecretRequestError({ reason: "store_failed", cause })), + ); + return yield* removeStored; + }).pipe(consumeLock.withPermits(1)); }); const sweepExpired = Effect.gen(function* () { @@ -241,14 +305,32 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret refs", cause)), Effect.withSpan("SecretRequests.sweepExpired"), ); - yield* sweepExpired; - yield* sweepExpired.pipe( + const sweepExpiredFiles = Effect.gen(function* () { + if (filesDir === undefined || !(yield* fileSystem.exists(filesDir))) return; + const now = yield* Clock.currentTimeMillis; + let removed = 0; + for (const name of yield* fileSystem.readDirectory(filesDir)) { + if (!FILE_NAME_PATTERN.test(name)) continue; + const file = path.join(filesDir, name); + const { mtime } = yield* fileSystem.stat(file); + if (Option.exists(mtime, (time) => now - time.getTime() <= SECRET_REF_TTL_MS)) continue; + yield* fileSystem.remove(file); + removed += 1; + } + yield* Effect.annotateCurrentSpan({ "secret_request.expired_files_removed": removed }); + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret files", cause)), + Effect.withSpan("SecretRequests.sweepExpiredFiles"), + ); + const sweep = Effect.andThen(sweepExpired, sweepExpiredFiles); + yield* sweep; + yield* sweep.pipe( Effect.delay("1 hour"), Effect.repeat(Schedule.spaced("1 hour")), Effect.forkScoped, ); - return SecretRequests.of({ answer, savedRef, consume }); + return SecretRequests.of({ answer, savedRef, consume, saveToFile }); }); export const layer = Layer.effect(SecretRequests, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2bf1da7813591..468b31926572e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -63,16 +63,23 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as PeerForwarding from "./peer/PeerForwarding.ts"; +import * as RemoteDelegation from "./peer/RemoteDelegation.ts"; +import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; @@ -80,6 +87,7 @@ import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; +import * as PreviewAgentActivity from "./preview/PreviewAgentActivity.ts"; import * as DesktopBrowserChannel from "./preview/DesktopBrowserChannel.ts"; import * as ServerBrowserStream from "./preview/ServerBrowserStream.ts"; import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; @@ -150,6 +158,7 @@ import { } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudHttp from "./cloud/http.ts"; +import * as GuestHttp from "./guest/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; import * as ConnectReachability from "./cloud/ConnectReachability.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; @@ -171,6 +180,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as TriageService from "./triage/TriageService.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClient.ts"; import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; @@ -209,15 +219,12 @@ import { } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; -// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's -// 100-character default for one path segment. +// Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. const HTTP_ROUTER_CONFIG = { maxParamLength: 512, } as const; -// Effect's default preemptive shutdown waits 20s before finalizing request scopes. -// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer -// already closes the websocket gracefully. +// Skip Effect's default 20 s grace; WebSocket scope finalizers already close connections gracefully. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const layerResourceAttribution = ResourceAttribution.layer; const layerApplicationObservability = EventLoopMonitor.layer.pipe( @@ -225,8 +232,7 @@ const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(layerResourceAttribution), ); -// One rotating store behind both logger views, so driver instances never race -// its rotation. +// Share rotation state so driver loggers cannot race. const layerProviderEventLoggers = Layer.effect( ProviderEventLoggers.ProviderEventLoggers, Effect.gen(function* () { @@ -271,8 +277,7 @@ const layerHostPowerMonitor = HostPowerMonitor.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); -// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here -// would open a second reader on the desktop telemetry fd. +// A fresh receiver layer would open a second reader on the desktop telemetry fd. const layerDesktopAppUpdate = DesktopAppUpdate.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); @@ -342,7 +347,7 @@ const layerHttpServer = Layer.unwrap( }), ); -const layerPlatformServices = NodeServices.layer; +const layerPlatformServices = DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -430,6 +435,9 @@ const layerVcs = Layer.empty.pipe( Layer.provideMerge(layerGitWorkflow), Layer.provideMerge(layerReview), Layer.provideMerge(layerSourceControlRepositoryService), + Layer.provideMerge( + SourceControlSignIn.layer.pipe(Layer.provide(layerSourceControlProviderRegistry)), + ), Layer.provideMerge(layerProjectCloneTracker), Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( @@ -669,6 +677,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ), Layer.provideMerge(ProviderRegistry.layer), Layer.provideMerge(ProviderInstanceRegistryHydration.layer), + // Provider hydration builds the Claude runner, which reports agents to the same scanner the + // terminal and preview layers share. + Layer.provideMerge(layerPortScanner), Layer.provideMerge( Layer.mergeAll( AntigravityInstallation.AntigravityInstallation.layer, @@ -725,6 +736,7 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(layerBackground), Layer.provideMerge(layerResourceDiagnostics), Layer.provideMerge(TraceDiagnostics.layer), + Layer.provideMerge(TriageService.layer.pipe(Layer.provide(GitVcsDriver.layer))), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), @@ -757,6 +769,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerOtlpTracesProxyRoute, ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, + GuestHttp.layer, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -773,15 +786,34 @@ const layerMakeRoutes = Layer.mergeAll( // Fork: T3 Agents tools run against this server's own RPC API. Layer.provide(LocalGatewayPort.layer), ), + // Follows delegated tasks running in linked environments, including after a restart. + Layer.effectDiscard( + RemoteDelegation.RemoteDelegation.pipe(Effect.flatMap((remote) => remote.start())), + ), ).pipe( Layer.provide( PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), ), - // Both transports consume the same service instance, so caches single-flight across clients - // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. + // delegate_task to a linked environment, from /mcp and the follower above. + Layer.provide(RemoteDelegation.layer.pipe(Layer.provide(ProjectionStoreV2.layer))), + // Links agents ask for: shown in a thread from /mcp, answered over WebSocket. + Layer.provide(PeerLinkRequests.layer), + // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), - Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), + // The same activity instance RunExecutionService reports to, by layer memoization. + Layer.provide( + ServerBrowser.layer.pipe( + Layer.provide(Layer.merge(DesktopBrowserChannel.layer, PreviewAgentActivity.layer)), + ), + ), Layer.provide(PreviewBrowser.layer), + // Links to other environments, shared by Settings (WebSocket) and agents (/mcp). + Layer.provide( + PeerForwarding.layer.pipe( + Layer.provideMerge(PeerMcpClient.layer), + Layer.provideMerge(PeerLinks.layer), + ), + ), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ClientFocusBroker.layer), Layer.provide(McpGatewayBroker.layer), @@ -971,11 +1003,8 @@ const layerMakeServer = Layer.unwrap( ), Effect.asVoid, ); - // The previous server is already gone, so the trial owns cleanup - // immediately; the pending-state check keeps the tunnel for normal - // commit or rollback, while the launcher's explicit-stop marker allows - // it to be released. Other runtimes wait for activation so a failed - // standby cannot tear down the active runtime's tunnel. + // Trials own cleanup immediately after the old server exits, retaining tunnels for commit/rollback unless explicitly stopped. + // Other runtimes wait for activation so failed standbys cannot tear down active tunnels. const cleanupBeforeActivation = yield* pendingServiceUpdateExists; if (cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); @@ -1025,8 +1054,7 @@ const layerMakeServer = Layer.unwrap( ), ) : false; - // A failed read must not end this fiber before it registers - // recovery and starts consuming recovery requests. + // Recover reads so this fiber still registers and consumes recovery requests. const desiredCliLinkMode = wantsCliLink ? yield* CloudCliState.readCliDesiredLinkMode.pipe( Effect.catch((cause) => @@ -1036,8 +1064,7 @@ const layerMakeServer = Layer.unwrap( ), ) : null; - // A publish-only link must not expose the host, even if a managed - // config from an earlier link is still stored. + // Publish-only links must not expose the host through old managed configs. const startedConfirmed = desiredCliLinkMode === "publish_only" ? false @@ -1087,8 +1114,7 @@ const layerMakeServer = Layer.unwrap( }).pipe(Effect.as({ status: "unavailable" as const })), ), ); - // Spread those registrations so an auto-update wave does not hit - // the relay all at once. + // Jitter registrations so update waves do not hit the relay simultaneously. if (!startedConfirmed && desiredCliLinkMode !== "publish_only") { const jitter = yield* Random.nextIntBetween( 0, diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 9765a25446e78..64b701cf6b1b2 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -134,7 +134,6 @@ const logInSpanThrough = ( return { requests, spans }; }); -/** Logs one warning through the server logger and returns what its console printed. */ const printThroughConsole = (fields: object) => Effect.gen(function* () { const printed: Array = []; @@ -154,6 +153,21 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { + it.effect("exports the operator's service instance id instead of a generated one", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otelEnvironment: { + ...OtelEnvironment.none, + resourceAttributes: { "service.instance.id": "pod-7" }, + }, + }); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"service.instance.id","value":{"stringValue":"pod-7"}'); + }), + ); + it.effect("exports a resource that names whether the desktop app launched the server", () => Effect.gen(function* () { const { requests } = yield* logInSpanThrough({ @@ -249,7 +263,6 @@ describe("ServerLoggerLive", () => { it.effect("keeps the token out of traces when the browser fails to open", () => Effect.gen(function* () { - // A failed launch records its error, which names the target, on its span. const failingLaunch = maybeOpenBrowser(pairingUrl).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index af7ea3805f208..d138175029799 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -11,12 +11,7 @@ import * as OtlpLogger from "effect/observability/OtlpLogger"; import * as ServerConfig from "./config.ts"; -/** - * `Logger.consolePretty` prints message objects with `util.inspect`, which - * collapses a `Cause` passed as a field (`{ cause }`) to - * `{ failures: [ [Object] ] }`. Render those fields with `Cause.pretty`, the - * same text a `Cause` passed as its own log argument already gets. - */ +// consolePretty collapses Cause fields via util.inspect; render them with Cause.pretty like standalone arguments. const consolePretty = (): Logger.Logger => { const pretty = Logger.consolePretty(); return Logger.make((options) => @@ -35,8 +30,7 @@ function prettyCauseFields(value: unknown): unknown { if (prototype !== Object.prototype && prototype !== null) return value; // A message with its own redaction hook already decides how it prints. if (Redactable.isRedactable(value)) return value; - // Copy descriptors rather than entries so getters are never invoked and - // symbol keys survive the copy. + // Copy descriptors to preserve symbols without invoking getters. const descriptors = Object.getOwnPropertyDescriptors(value); let changed = false; for (const descriptor of Object.values(descriptors)) { @@ -64,13 +58,8 @@ export const layer = Effect.gen(function* () { resource: ServerConfig.otlpResource(config, hostName), }); - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the server wants has to be named in this one call. - // `Logger.tracerLogger` reaches a collector by attaching each message to the - // active span as a span event, which covers only messages logged inside a - // recorded span and files them under traces. The OTLP logger carries the same - // messages as log records stamped with their trace and span ids, so it is a - // superset: keeping both would export every in-span message twice. + // Logger.layer replaces the whole set; name every logger here. + // OTLP includes traced messages as log records, so also using tracerLogger would duplicate in-span exports. const layerLogger = Logger.layer( otlpLogger === undefined ? [consolePretty(), Logger.tracerLogger] diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 2ed1b311ea234..2392e97dfbfb1 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -44,8 +44,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError (("errcode" in cause && cause.errcode === 5) || ("code" in cause && cause.code === "SQLITE_BUSY")); -/** - * Servers from before this lock only publish `server-runtime.json`. Treat - * their record as stale only when the process start time proves PID reuse. - */ +// Pre-lock servers publish only runtime state; require start-time proof of PID reuse before treating it as stale. const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* ( state: PersistedServerRuntimeState, ) { @@ -213,14 +209,7 @@ const stoppingOwnerRemaining = Effect.fn("stoppingOwnerRemaining")(function* (st return remaining > 0 ? Option.some(Duration.millis(remaining)) : Option.none(); }); -/** - * Hold an exclusive lock on the state directory until the scope closes. The - * lock is an exclusive transaction on `server-owner.sqlite`, which never holds - * data and must never be unlinked: the OS releases it when the process exits, - * even on SIGKILL, so a crashed owner never blocks the next start. An owner - * that is shutting down is waited for until its deadline; a running one is - * refused at once. - */ +/** Never unlink the ownership database: its transaction lock survives path reuse and releases on process exit. */ export const acquireServerOwnership = Effect.fn("acquireServerOwnership")(function* ( statePath: string, ) { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 5197fe6fc8e93..a912f57e9d99a 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -213,6 +213,7 @@ it.effect.each([ }), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ managed: false, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. prepareTrial: Effect.succeed(undefined), }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 14fe96da8d634..c143d9573ac92 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -346,19 +346,13 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { : yield* serverAuth.issueStartupPairingUrl(baseTarget); }); -/** - * Prints the startup pairing URL to stdout, where `t3` users and the dev runner - * read it. The URL carries a one-time admin token, so it skips the logger: log - * records also land in the trace file and any configured OTLP collector. - */ +/** Prints the one-time admin pairing URL to stdout only; logger records reach trace files and collectors. */ export const announceStartupPairingUrl = (pairingUrl: string) => Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( - // Indented to match the annotation line the pretty logger printed before. Effect.andThen(Console.log(` pairingUrl: ${pairingUrl}`)), ); -// No URL here: the browser target carries the pairing token, which -// announceStartupPairingUrl has already printed to stdout. +// Pairing tokens belong only in announceStartupPairingUrl's stdout output. export const logBrowserAutoOpenUnavailable = Effect.logInfo("browser auto-open unavailable", { hint: "Open the pairing URL above in your browser.", }); @@ -382,9 +376,7 @@ export const maybeOpenBrowser = Effect.fn("serverRuntimeStartup.maybeOpenBrowser const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( - // Attributes on the span itself, not annotateSpans: an annotation follows - // every fiber forked inside the phase, such as the effect worker, for the - // life of the server. + // Use span attributes; annotations would follow long-lived forked fibers for the server's lifetime. Effect.withSpan(`server.startup.${phase}`, { attributes: { "startup.phase": phase } }), ); @@ -739,5 +731,3 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup export const layerWithOptions = (options?: StartupOptions) => Layer.effect(ServerRuntimeStartup, make(options)); - -const layer = layerWithOptions(); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 91a77bedeec03..2cb22109b91b5 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -118,10 +118,7 @@ export const clearPersistedServerRuntimeState = Effect.fn( ); }); -/** - * Signal 0 delivers nothing; it only reports whether the pid exists. EPERM - * means it exists but belongs to another user, which still counts as alive. - */ +/** Signal 0 only probes existence; EPERM still means alive. */ export const isProcessAlive = (pid: number): boolean => { if (!Number.isInteger(pid) || pid <= 0) return false; try { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6a8a8acec4980..1cf8bfe4a669a 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -558,8 +558,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("acpRegistry_shared"); - const results = yield* Effect.all( - ["First", "Second"].map((displayName) => + const results = yield* Effect.forEach( + ["First", "Second"], + (displayName) => serverSettings .updateProviderInstance({ operation: "create", @@ -571,7 +572,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }) .pipe(Effect.result), - ), { concurrency: "unbounded" }, ); @@ -843,7 +843,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - // A backend behind a custom model may refuse the built-in default (#12651). + // A backend behind a custom model may refuse the built-in default. it.effect("falls back to the thread default when it is a custom model on that instance", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1472,7 +1472,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); } - // Editing another field from the client's copy keeps the saved password. const clientConfig = client.providerInstances[opencode]?.config as Record; const edited = yield* service.updateProviderInstance({ operation: "upsert", @@ -1886,10 +1885,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { runOnWorktreeCreate: false, }; const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); - const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); - const scriptsJson = yield* Schema.encodeEffect( + const encodeModelSelection = Schema.encodeEffect(Schema.fromJsonString(ModelSelection)); + + const modelJson = yield* encodeModelSelection(model); + const encodeProjectScripts = Schema.encodeEffect( Schema.fromJsonString(Schema.Array(ProjectScript)), - )([script]); + ); + + const scriptsJson = yield* encodeProjectScripts([script]); for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ [legacyProject, modelJson, "worktree", 1, scriptsJson], [scriptedProject, null, null, 0, scriptsJson], diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index cfdce7b7ebe00..5d42f9bc090f2 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -73,18 +73,13 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -/** - * Old settings files can hold both flags with conflicting values; an explicit false on either - * side wins so a user's disable is never silently undone. - */ +// Conflicting flags resolve to false to preserve user disables. const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => { let changed = false; const providerInstances: Record = {}; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { const config = instance.config; - // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must - // stay in the blob so driver schema validation flags it instead of the - // fold silently repairing the config. + // Leave malformed flags for driver schema validation rather than silently repairing them. if ( config === null || typeof config !== "object" || @@ -201,10 +196,7 @@ export class ServerSettingsService extends Context.Service< readonly streamChanges: Stream.Stream; - /** - * Use this before reading a snapshot when changes between the snapshot and - * a lazily started stream must not be lost. - */ + /** Subscribe before reading a snapshot to retain changes before lazy stream startup. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { @@ -275,8 +267,7 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); -// Migrate saved token delivery without accepting it in settings writes or -// letting one retired value reset the rest of the environment's settings. +// Accept retired token delivery only on load so one old value cannot reset all settings. const PersistedResponseStreamingMode = Schema.Union([ ResponseStreamingMode, Schema.Literal("token"), @@ -641,9 +632,7 @@ interface LegacyProjectSettingsRow { readonly scripts: string; } -/** - * Marked with `projectSettingsFolded` so a later reset in the UI survives restarts. - */ +// projectSettingsFolded prevents restarts from undoing later UI resets. function foldLegacyProjectSettings( settings: ServerSettings, rows: ReadonlyArray, @@ -676,8 +665,7 @@ function foldLegacyProjectSettings( for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { set(projectId, "defaultAutoPull", value); } - // A stored null meant "reset to machine defaults", which is now plain - // inheritance; the project's own aggregate scripts must not resurface. + // A stored null resets to machine defaults, so the project's aggregate scripts must not resurface. const resetScripts = new Set(); for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { if (value === null) resetScripts.add(projectId); @@ -900,9 +888,7 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - // Copied, not moved: the old entry is removed only after the new file is written. A token - // that cannot be read or copied leaves the retired keys in place, so the migration runs again - // on the next load instead of dropping it. + // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. const legacySecretsCopied = yield* Effect.forEach( settingsFileTrusted ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves @@ -1236,9 +1222,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // A symlinked settings file is rewritten in its destination's directory, - // which a watch on the link's directory never sees. The link is resolved - // again whenever it changes, so repointing it moves the watch along. + // Watch symlink targets' directories for rewrites; re-resolve link changes to follow repointing. const watchLinkTarget = Effect.gen(function* () { const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -1266,8 +1250,7 @@ const make = Effect.gen(function* () { ), ); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedSettingsEvents = Stream.merge( watchFileChanges(settingsPath), linkTargetEvents, diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index c168650acf0fe..edecfbba3887f 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,9 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// The launcher supervises the server child for the boot service and must keep -// working across server versions, so it stays on Node built-ins with no Effect -// runtime: it is the one part of the executable that cannot depend on the -// rest of it being loadable. +// The launcher must survive unloadable server versions; use Node built-ins without Effect. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; @@ -39,8 +36,7 @@ const HANDOFF_DELAY_MS = 2_000; */ const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; -// Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone -// launcher stays independent of the Effect-backed contracts package. +// Match contracts' SERVER_EXIT_CODE_STATE_DIR_OWNED without depending on Effect. const STATE_DIR_OWNED_EXIT_CODE = 78; // Matches SERVER_EXIT_CODE_DATABASE_REFUSED in contracts, for the same reason. const DATABASE_REFUSED_EXIT_CODE = 65; @@ -129,9 +125,7 @@ async function syncFile(filePath: string): Promise { } } -// Flushes a directory entry so a rename into it survives power loss. Windows -// has no directory fsync: the handle opens but sync fails with EPERM, and -// NTFS journals the rename on its own. +// Fsync makes renames durable; Windows returns EPERM and NTFS journals them itself. async function syncDirectory(directory: string): Promise { const handle = await NodeFSP.open(directory, "r"); try { @@ -149,14 +143,11 @@ function runCp(args: ReadonlyArray): Promise { }); } -/** - * Node's own clone flag is not used: on macOS `fs.copyFile` falls back to a full copy. - */ +// macOS fs.copyFile clone flags fall back to full copies. async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; - // `cp -c` replaces the destination with a new file, which would cut a - // hard-linked database off from its other links; copy into it instead. + // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. const hardLinked = platform === "darwin" && ((await NodeFSP.stat(destination).catch(() => undefined))?.nlink ?? 1) > 1; @@ -170,10 +161,7 @@ async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; @@ -238,10 +221,7 @@ const filesystemBackupSpaceProbe: BackupSpaceProbe = { canClone: backupCanClone, }; -/** - * Runs before the server is stopped so a full disk refuses the update instead of causing an - * outage and a cold restart. - */ +/** Check before stopping the server so full disks refuse updates without an outage. */ export async function databaseBackupBlocker( baseDir: string, dbPath: string, @@ -264,10 +244,7 @@ export async function databaseBackupBlocker( return `Not enough free disk space to back up the database before updating: ${gb(needed)} GB needed, ${gb(free)} GB free. Free up disk space and try again. The server was not stopped.`; } -/** - * A completed backup is never overwritten because a restarted launcher may be looking at - * database writes from an earlier attempt by the same trial. - */ +// Preserve completed backups; restarted trials may already have changed the live database. async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -297,7 +274,7 @@ const restoreMarkerPath = (baseDir: string, updateId: string) => const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) => pathExists(restoreMarkerPath(baseDir, pending.id)); -/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */ +// Mark before live writes so recovery cannot boot a partial restore. async function markDatabaseRestorePending(backupDir: string): Promise { const markerPath = NodePath.join(backupDir, RESTORE_MARKER); if (!(await pathExists(markerPath))) { @@ -311,11 +288,7 @@ async function markDatabaseRestorePending(backupDir: string): Promise { } } -/** - * The file a restore writes for `path`: where its symlinks lead, even when the - * last target is missing, as after a crash between `cp -c` removing a file - * and cloning over it. - */ +// Resolve symlink targets even if a crashed cp -c left the final target missing. async function restoreTarget(path: string): Promise { let current = path; for (let hops = 0; hops < 40; hops++) { @@ -330,8 +303,7 @@ async function restoreTarget(path: string): Promise { throw cause; }); if (link === undefined) return current; - // Joined without normalizing: a `..` in the link must follow directory links - // the way the filesystem does, not cancel a path segment. + // Do not normalize .. before following directory symlinks; match filesystem resolution. current = NodePath.isAbsolute(link) ? link : `${await NodeFSP.realpath(NodePath.dirname(current))}${NodePath.sep}${link}`; @@ -352,8 +324,7 @@ async function restoreDatabaseBackup( const livePath = `${pending.dbPath}${suffix}`; const source = databaseBackupFile(backupDir, suffix); if (await pathExists(source)) { - // A clone replaces its destination, so restore into the file a symlinked - // database resolves to and keep the link itself. + // Restore into the symlink target; cloning would replace the link. const target = await restoreTarget(livePath); await copyDatabaseFile(source, target); await syncFile(target); @@ -528,9 +499,7 @@ export class Launcher { } async stop(signal: NodeJS.Signals): Promise { - // This must happen synchronously at signal receipt. A queued update - // transition may already be terminating the active child, and that child - // needs to see the marker in its shutdown finalizer. + // Write synchronously on signal receipt; a terminating child needs the marker in its finalizer. try { NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); } catch {} @@ -541,9 +510,7 @@ export class Launcher { this.#stopRequested = true; this.#clearTimer(); this.#enqueue(async () => { - // Let an update transition already in progress start its replacement - // before this queued stop tears it down. That replacement owns the - // pre-activation tunnel cleanup path and observes the marker above. + // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. this.#stopping = true; const child = this.#child?.process; this.#child = null; @@ -560,13 +527,7 @@ export class Launcher { } async #recover(): Promise { - // A fresh launcher means servers are running again: any stop marker from - // a previous explicit stop is stale and must not make a future update - // handoff release its tunnel. A restart deferred by `t3 update` is done - // no matter who restarted the service, but only once this launcher is - // the version the marker waits for: a launcher that came up between the - // CLI writing the marker and writing the new state still runs the old - // version, and the marker has to outlive it. + // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); @@ -702,8 +663,7 @@ export class Launcher { await reject("The requested target runtime is missing or incomplete."); return; } - // A check that cannot run refuses the update too: the alternative is - // stopping the server without knowing whether the backup fits. + // Failed checks must refuse updates rather than stop the server with unknown backup capacity. const backupBlocker = await databaseBackupBlocker( this.#baseDir, message.dbPath, @@ -822,10 +782,7 @@ export class Launcher { throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); } - /** - * A pending update fails without restoring its backup: the other owner may - * have written to the database since. - */ + // Stay idle to avoid supervisor restart loops; never restore backups over another owner's possible writes. async #idleWhileStateDirOwned(): Promise { this.#clearTimer(); const pending = this.#state.update; diff --git a/apps/server/src/sourceControl/ServerSourceControlHost.ts b/apps/server/src/sourceControl/ServerSourceControlHost.ts index de62afef246a4..73e79a8cc49f3 100644 --- a/apps/server/src/sourceControl/ServerSourceControlHost.ts +++ b/apps/server/src/sourceControl/ServerSourceControlHost.ts @@ -15,7 +15,7 @@ export const layer = Layer.effect( const git = yield* GitVcsDriver.GitVcsDriver; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; return SourceControlHost.SourceControlHost.of({ - settings: { get: serverSettings.getSettings }, + settings: { get: serverSettings.getSettings, update: serverSettings.updateSettings }, process: { run: process.run }, git: { execute: git.execute, @@ -29,6 +29,8 @@ export const layer = Layer.effect( fetchRemoteBranch: git.fetchRemoteBranch, fetchRemoteTrackingBranch: git.fetchRemoteTrackingBranch, setBranchUpstream: git.setBranchUpstream, + createBranch: git.createBranch, + writeConfigValue: git.writeConfigValue, switchRef: git.switchRef, }, }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index f56985fefd639..a22b83baaf2f4 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -64,30 +64,18 @@ export class SourceControlDiscovery extends Context.Service< } >()("t3/sourceControl/SourceControlDiscovery") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const process = yield* VcsProcess.VcsProcess; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const probe = ( - input: DiscoveryProbe & { readonly kind: Kind }, + input: VcsProbe & { readonly kind: Kind }, ): Effect.Effect> => { const executable = input.executable; const versionArgs = input.versionArgs; - if (!executable || !versionArgs) { - return Effect.succeed({ - kind: input.kind, - label: input.label, - implemented: input.implemented, - status: "missing" as const, - version: Option.none(), - installHint: input.installHint, - detail: Option.some(input.installHint), - } satisfies DiscoveryProbeResult); - } - return process .run({ operation: "source-control.discovery.probe", diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index eee2598d8c0e8..36f5e7642ae60 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -12,7 +12,6 @@ import { SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; -import type {} from "@t3tools/contracts"; import * as BuiltInDrivers from "./builtInDrivers.ts"; import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; @@ -72,11 +71,10 @@ export class SourceControlProviderRegistry extends Context.Service< function unsupportedProvider( kind: SourceControlProviderKind, ): SourceControlProvider.SourceControlProvider["Service"] { + // Binding context does not create a second provider-operation span. return SourceControlProvider.SourceControlProvider.of({ kind, - // A remote on a host no provider recognizes (or no remote at all) has no change - // requests T3 could know about. Answering with none keeps the status poller from - // failing, and warning, on every refresh of such a repository. + // Unknown hosts have no readable change requests; return none to avoid poll warnings on every refresh. listChangeRequests: (input) => kind === "unknown" ? Effect.succeed([]) @@ -162,10 +160,7 @@ function selectProviderContext( ); } -/** - * `cwd` is left out because it only matters when a probe fails there, and a failed probe - * is not kept. Equality ignores `lookup`, so this is not a `Data.Class`. - */ +// Failed probes are not cached, so cwd is irrelevant; equality ignores lookup, unlike Data.Class. class UnknownRemoteLookup { readonly key: string; readonly lookup: Effect.Effect; @@ -201,41 +196,49 @@ function bindProviderContext( return SourceControlProvider.SourceControlProvider.of({ ...provider, - listChangeRequests: (input) => - provider.listChangeRequests({ + listChangeRequests: Effect.fnUntraced(function* (input) { + return yield* provider.listChangeRequests({ ...input, context: input.context ?? context, - }), - getChangeRequest: (input) => - provider.getChangeRequest({ + }); + }), + getChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.getChangeRequest({ ...input, context: input.context ?? context, - }), - createChangeRequest: (input) => - provider.createChangeRequest({ + }); + }), + createChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.createChangeRequest({ ...input, context: input.context ?? context, - }), - getRepositoryCloneUrls: (input) => - provider.getRepositoryCloneUrls({ + }); + }), + getRepositoryCloneUrls: Effect.fnUntraced(function* (input) { + return yield* provider.getRepositoryCloneUrls({ ...input, context: input.context ?? context, - }), - createRepository: (input) => provider.createRepository(input), - getDefaultBranch: (input) => - provider.getDefaultBranch({ + }); + }), + createRepository: Effect.fnUntraced(function* (input) { + return yield* provider.createRepository(input); + }), + getDefaultBranch: Effect.fnUntraced(function* (input) { + return yield* provider.getDefaultBranch({ ...input, context: input.context ?? context, - }), - checkoutChangeRequest: (input) => - provider.checkoutChangeRequest({ + }); + }), + checkoutChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.checkoutChangeRequest({ ...input, context: input.context ?? context, - }), + }); + }), }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig.ServerConfig; @@ -342,7 +345,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, get, resolveHandle, - resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + resolve: Effect.fn("SourceControlProviderRegistry.resolve")(function (input) { + return resolveHandle(input).pipe(Effect.map((handle) => handle.provider)); + }), discover: Effect.forEach( discoverySpecs, (spec) => diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index ceb9ec33288de..b6e7679037040 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -73,14 +73,10 @@ export interface SourceControlCloneOptions { readonly timeoutMs?: number | null; } -// The synchronous RPC (older clients, mobile) keeps a deadline: nothing else -// tells the user a clone stalled. The tracked path passes null and relies on -// progress and Cancel instead. +// Older/mobile synchronous RPCs need deadlines; tracked clones use progress and Cancel instead. const CLONE_TIMEOUT_MS = 120_000; const CLONE_ENV = { - // `--progress` forces the transfer counters through the pipe; the delay env - // makes the checkout counter start immediately. No tty means a credential - // prompt would hang forever, so tell git to fail instead. + // Force piped progress immediately; refuse credential prompts that would hang without a tty. GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", @@ -114,11 +110,7 @@ function toRepositoryInfo( }; } -/** - * The URL clients see. A pasted `https://user:token@host/…` must not travel - * back over `subscribeProjectClones` to every reader; git still gets the - * original. - */ +// Redact credentials from client clone snapshots; git still receives the original URL. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -135,20 +127,15 @@ function redactRemoteUrl(remoteUrl: string): string { } } -// Userinfo may itself contain `@`; everything up to the last one before the -// host boundary goes. +// Userinfo can contain @; remove through the last one before the host boundary. const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; function redactUrlCredentials(text: string): string { return text.replace(URL_WITH_USERINFO, "$1"); } -/** - * Explains a failed first push of a published repository. The repository - * already exists by then, so publishing again cannot help; the user has to - * fix access and push. Built from fixed text and the provider's URLs only, - * since git's stderr can carry credentials. - */ +// The remote already exists after push failure; advise fixing access and pushing. +// Use fixed text and provider URLs because git stderr can expose credentials. function publishPushFailureDetail(input: { readonly urls: SourceControlRepositoryCloneUrls; readonly remoteName: string; @@ -171,12 +158,8 @@ function publishPushFailureDetail(input: { } /** - * Adding a project clones over SSH for most hosts, and a machine with no SSH - * key there would fail every time, while the host's HTTPS credential helper - * may be set up. A clone whose SSH login was refused is retried once over - * HTTPS, unless the caller asked for SSH. A looked-up repository has the - * provider's own HTTPS URL; any other URL is rewritten, which is refused - * where it would have to guess. + * SSH rejection can be retried over HTTPS when the host's credential helper is available. + * Explicit SSH requests must not fall back; unknown URL shapes must not be guessed. */ function httpsFallbackUrl( input: SourceControlCloneRepositoryInput, @@ -204,7 +187,7 @@ function selectRemoteUrl( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -344,9 +327,7 @@ export const make = Effect.gen(function* () { options?: SourceControlCloneOptions, ) { const onProgress = options?.onProgress; - // Git interleaves progress redraws with its real messages on stderr. The - // last non-progress lines are what explain a failure ("Repository not - // found", "Permission denied"), so keep them for the error detail. + // Git mixes progress and diagnostics on stderr; retain non-progress tail lines for failure details. const stderrTail: Array = []; const onStderrLine = (line: string) => { const parsed = parseGitCloneProgressLine(line); @@ -365,9 +346,7 @@ export const make = Effect.gen(function* () { cwd: path.dirname(destinationPath), args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, - // Progress redraws add up on a slow multi-GB clone. The buffered copy - // is never read (the tail is kept by hand above), so keep it small - // and let the line callbacks keep flowing past the cap. + // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, appendTruncationMarker: true, keepLineCallbacksAfterTruncation: true, @@ -403,6 +382,7 @@ export const make = Effect.gen(function* () { options, ).pipe( Effect.as(prepared.remoteUrl), + // @effect-diagnostics-next-line catchConditionalRefailToCatchIf:off - The condition also narrows httpsUrl for the fallback closure. Effect.catch((sshError) => httpsUrl === null || !isSshRejection(sshError) ? Effect.fail(sshError) @@ -432,11 +412,8 @@ export const make = Effect.gen(function* () { destinationPath: string, ) { const normalized = yield* normalizeDestinationPath(destinationPath); - // Only what git left behind may go. The destination was empty when the - // clone started, so anything without a `.git` inside was put there by - // someone else since; refuse rather than delete their files. - // A missing destination is already discarded; any other read failure - // (a file in its place, permissions) is not something to remove through. + // Only .git-bearing partial clones may be deleted; other files arrived after the empty-directory check. + // Missing destinations are already discarded; other read failures must refuse removal. const entries = yield* fileSystem.readDirectory(normalized).pipe( Effect.catchIf( (cause) => cause.reason._tag === "NotFound", @@ -459,9 +436,7 @@ export const make = Effect.gen(function* () { detail: "Destination path contains files that are not from the clone.", }); } - // The directory itself is the project's workspace root and must stay; - // only git's partial contents go. An interrupted git may still be closing - // files, so removal retries briefly. + // Preserve the workspace directory after removing partial contents; retry while interrupted git closes files. yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), @@ -496,10 +471,7 @@ export const make = Effect.gen(function* () { url: remoteUrl, }); - // An empty local repo (no commits) would make `git push HEAD:...` fail - // with an opaque "src refspec HEAD does not match any". Treat this as a - // partial success: the remote was created and wired up, but there is - // nothing to push yet. + // Empty repos cannot push HEAD; report partial success because the remote already exists. const hasCommits = yield* git .execute({ operation: "SourceControlRepositoryService.publishRepository.headCheck", @@ -543,35 +515,44 @@ export const make = Effect.gen(function* () { remoteName, remoteUrl, branch: pushResult.branch, - ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), status: "pushed" as const, }; }, ); + // Forwarders retain the underlying operation spans. return SourceControlRepositoryService.of({ - lookupRepository: (input) => - lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), - prepareClone: (input) => - prepareClone(input).pipe( + lookupRepository: Effect.fnUntraced(function* (input) { + return yield* lookupRepository(input).pipe( + mapRepositoryError("lookupRepository", input.provider), + ); + }), + prepareClone: Effect.fnUntraced(function* (input) { + return yield* prepareClone(input).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - cloneRepository: (input, options) => - cloneRepository(input, options).pipe( + ); + }), + cloneRepository: Effect.fnUntraced(function* (input, options) { + return yield* cloneRepository(input, options).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - discardClone: (destinationPath) => - discardClone(destinationPath).pipe( + ); + }), + discardClone: Effect.fnUntraced(function* (destinationPath) { + return yield* discardClone(destinationPath).pipe( mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), - ), - publishRepository: (input) => - publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + ); + }), + publishRepository: Effect.fnUntraced(function* (input) { + return yield* publishRepository(input).pipe( + mapRepositoryError("publishRepository", input.provider), + ); + }), }); }); diff --git a/apps/server/src/sourceControl/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts new file mode 100644 index 0000000000000..d6c2ab1a69a33 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -0,0 +1,69 @@ +import { + type SourceControlAppAccess, + type SourceControlAppAccessInput, + SourceControlSignInError, + type SourceControlSignInInput, + type SourceControlSignInPrompt, + type SourceControlSignInResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as SourceControlBuiltInDrivers from "./builtInDrivers.ts"; + +/** Signs the server in to a forge through the forge's own app, for hosts whose driver offers it. */ +export class SourceControlSignIn extends Context.Service< + SourceControlSignIn, + { + readonly start: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly wait: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly cancel: ( + input: SourceControlSignInInput, + ) => Effect.Effect; + readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; + } +>()("t3/sourceControl/SourceControlSignIn") {} + +const make = Effect.gen(function* () { + const drivers = yield* SourceControlBuiltInDrivers.SourceControlDrivers; + + const signInFor = (input: SourceControlSignInInput) => { + const signIn = drivers.find(({ driver }) => driver.kind === input.provider)?.instance.signIn; + return signIn === undefined + ? Effect.fail( + new SourceControlSignInError({ + provider: input.provider, + host: input.host, + reason: "unsupported", + detail: `This server cannot sign in to ${input.provider}. Use a token or the host's CLI.`, + }), + ) + : Effect.succeed(signIn); + }; + + return SourceControlSignIn.of({ + start: Effect.fn("SourceControlSignIn.start")(function* (input) { + return yield* (yield* signInFor(input)).start(input.host); + }), + wait: Effect.fn("SourceControlSignIn.wait")(function* (input) { + return yield* (yield* signInFor(input)).wait(input.host); + }), + cancel: Effect.fn("SourceControlSignIn.cancel")(function* (input) { + return yield* (yield* signInFor(input)).cancel(input.host); + }), + access: Effect.fn("SourceControlSignIn.access")(function* (input) { + const signIn = drivers.find(({ driver }) => driver.kind === input.provider)?.instance.signIn; + return { + status: + signIn === undefined ? "signed-out" : yield* signIn.access(input.host, input.repository), + } satisfies SourceControlAppAccess; + }), + }); +}); + +export const layer = Layer.effect(SourceControlSignIn, make); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index cd2815931e307..910584d110b31 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; +import { buildPairingAppLink } from "@t3tools/shared/remote"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; @@ -186,6 +187,7 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): `Connection string: ${accessInfo.connectionString}`, `Token: ${accessInfo.token}`, `Pairing URL: ${accessInfo.pairingUrl}`, + `App link: ${buildPairingAppLink(accessInfo.pairingUrl)}`, "", renderTerminalQrCode(accessInfo.pairingUrl), "", diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 21eef3177ecea..a8266d8a7d299 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -776,7 +776,6 @@ describe("subagent worktree ownership", () => { id: ThreadId.make("grandchild"), lineage: { ...child.lineage, parentThreadId: child.id }, }; - // The worktree owner can follow its children in the snapshot. fixture.setThreads( scenario === "grandchild" ? [grandchild, child, root] diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index e7ad8ca71912d..cd5cee5bdc717 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -67,8 +67,7 @@ const decodePnpmModulesManifest = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; -// After a settle, the project's settle script needs time to open its -// terminal, which then protects the worktree. +// Give settle scripts time to open terminals that protect the worktree. const SETTLE_SCRIPT_GRACE_MS = 60_000; // Worktree creation can precede its thread row, including review-guide checkouts. const ORPHAN_WORKTREE_GRACE_MS = 60 * 60 * 1_000; @@ -145,13 +144,7 @@ export function storageCleanupThreadIdle(thread: OrchestrationV2ThreadShell, now ); } -/** - * Working directories of provider sessions that may still be open. A row bound - * to a thread, or still starting, counts as open. An unbound row counts only - * while this process holds the session: settling or archiving detaches the - * thread before the session is released, and rows from such releases can be - * left "ready" after their process is gone. - */ +/** Bound and starting sessions remain open; detached ready rows count only while this process holds them. */ export const readLiveProviderSessionCwds = Effect.fn("StorageCleanup.readLiveProviderSessionCwds")( function* () { const sql = yield* SqlClient.SqlClient; @@ -216,11 +209,7 @@ function worktreeOwner null), ); - // The snapshot takes seconds on large databases, so it runs on the reader - // worker instead of the event loop. + // Large snapshots take seconds; use the reader worker instead of blocking the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const snapshot = yield* projections .getShellSnapshot() @@ -407,7 +395,6 @@ const make = Effect.gen(function* () { projects: ReadonlyArray<{ readonly workspaceRoot: string }>, entries: StorageCleanupReportEntry[], ) { - // Include deleted and archived threads, even those without a branch or project. const rows = yield* sql<{ worktreePath: string }>` SELECT DISTINCT json_extract(payload_json, '$.worktreePath') AS "worktreePath" FROM orchestration_v2_projection_threads @@ -459,9 +446,7 @@ const make = Effect.gen(function* () { } return result; }); - // Let the first walk reach its own limit so unmeasurable folders get cached. - // Later walks share the remaining sweep budget. - // Unmeasured folders still appear with an unknown size. + // Let the first walk time out and cache unmeasurable folders; later walks share the remaining budget. const now = yield* Clock.currentTimeMillis; const measurementDeadline = now + 30_000; let firstMeasurement = true; @@ -484,7 +469,7 @@ const make = Effect.gen(function* () { const remaining = measurementDeadline - (yield* Clock.currentTimeMillis); if (!orphanSizes.has(key) && (firstMeasurement || remaining > 0)) { const measured = yield* measureWorktree(worktreePath).pipe( - firstMeasurement ? Effect.map(Option.some) : Effect.timeoutOption(remaining), + firstMeasurement ? Effect.asSome : Effect.timeoutOption(remaining), ); firstMeasurement = false; if (Option.isSome(measured)) orphanSizes.set(key, measured.value); @@ -527,9 +512,7 @@ const make = Effect.gen(function* () { } }); - // Whether cwd stopped being a checkout of this branch. Two small Git reads, - // since every worktree in a sweep is asked; statusDetailsLocal also diffs and - // counts. + // Use two small Git reads: statusDetailsLocal also diffs and counts for every worktree. const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( cwd: string, branch: string | null, @@ -541,8 +524,7 @@ const make = Effect.gen(function* () { cwd, detail, }); - // Null when cwd is not a repository, or is gone. Git's own messages are - // matched, so they must not be translated. + // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { const result = yield* git .execute({ @@ -561,7 +543,12 @@ const make = Effect.gen(function* () { ); if (result === null) return null; if (result.exitCode === 0 || result.exitCode === allowedExitCode) return result; - if (result.stderr.toLowerCase().includes("not a git repository")) return null; + const diagnostic = result.stderr.toLowerCase(); + if ( + diagnostic.includes("not a git repository") || + diagnostic.includes("gitfile does not point to a valid repository") + ) + return null; return yield* failure( result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", ); @@ -572,8 +559,7 @@ const make = Effect.gen(function* () { return yield* failure( "Git index is locked. Status will resume when the index lock is removed.", ); - // Exits 1 for a detached HEAD. Reads the full ref name, which is never - // shortened to "heads/" when a tag shares the branch name. + // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); if (head === null) return true; const ref = head.exitCode === 0 ? head.stdout.trim() : ""; @@ -750,14 +736,12 @@ const make = Effect.gen(function* () { timeoutMs: 300_000, }); if (result.exitCode !== 0) - return yield* Effect.fail( - new GitCommandError({ - operation: "StorageCleanup.cleanupGit", - command, - cwd, - detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", - }), - ); + return yield* new GitCommandError({ + operation: "StorageCleanup.cleanupGit", + command, + cwd, + detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + }); }); // What the latest sweep, and the one running now, kept only until a provider @@ -875,8 +859,7 @@ const make = Effect.gen(function* () { 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"); - // Roots are canonical, so compare canonical paths. A symlinked parent - // (a linked drive) is fine; a symlinked worktree directory is not. + // 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)); if (realPath !== path.join(realParent, path.basename(worktreePath))) @@ -998,10 +981,7 @@ const make = Effect.gen(function* () { ? "not merged" : "has commits beyond the default branch", ); - // Threads, queued deletion work and provider sessions as they are now, so a - // queued turn, resumed session or new thread sharing this path cancels - // the removal. Read only this path's threads: a full snapshot per - // worktree takes seconds on large databases. + // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. const blockedSinceCheck = Effect.fnUntraced(function* () { const latestProjects = yield* projectStore.listShells(); if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) @@ -1016,8 +996,7 @@ const make = Effect.gen(function* () { .worktreeOnDelete ) return "thread or cleanup settings changed since check"; - // V2 deletion queues durable cleanup. Do not remove its checkout until - // every effect has finished successfully or was explicitly cancelled. + // 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 @@ -1055,8 +1034,7 @@ const make = Effect.gen(function* () { const sessions = yield* Effect.forEach(sessionRows, (row) => decodeCleanupSession(row.payload_json), ); - // A stored session can stay ready after its runtime was released, - // for example once every thread detached; only a live one holds it. + // Ready persisted sessions may have released their runtime; only live sessions protect the path. for (const session of sessions) { const cwd = path.resolve(session.cwd); if ( @@ -1112,8 +1090,7 @@ const make = Effect.gen(function* () { }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderTurnStartService recreates the checkout - // from that branch when the thread is resumed. + // Preserve branch/path so ProviderTurnStartService can recreate the checkout on resume. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 1cc66be7b3dfe..417841cb53e39 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -80,6 +81,24 @@ const respondWithStatus = (request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))); +/** + * Answers 503 for `minutes` after the first send, then 204. `answers` holds each + * answer with its time in ms after the first send (the test clock runs on across tests). + */ +const failingFor = (minutes: number) => { + const answers: Array<{ readonly at: number; readonly status: number }> = []; + let firstSend: number | undefined; + const respond: Respond = (request) => + Effect.flatMap(Clock.currentTimeMillis, (now) => { + firstSend ??= now; + const at = now - firstSend; + const status = at < minutes * 60_000 ? 503 : 204; + answers.push({ at, status }); + return respondWithStatus(status)(request); + }); + return { respond, answers }; +}; + const recordTwentyEvents = Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; for (let index = 0; index < 20; index += 1) { @@ -88,11 +107,12 @@ const recordTwentyEvents = Effect.gen(function* () { }); /** - * Runs `record` (20 events by default), then lets ten minutes of background + * Runs `record` (20 events by default), then lets `minutes` of background * flushes run against a client that answers every send with `respond`. * Returns the sent batches. */ -const sendForTenMinutes = ( +const sendFor = ( + minutes: number, respond: Respond, record: Effect.Effect< void, @@ -137,7 +157,7 @@ const sendForTenMinutes = ( yield* Effect.gen(function* () { yield* record; - for (let second = 0; second < 600; second += 1) { + for (let second = 0; second < minutes * 60; second += 1) { yield* TestClock.adjust("1 second"); } }).pipe(Effect.provide(runtimeLayer)); @@ -147,7 +167,8 @@ const sendForTenMinutes = ( it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("client owner reports cannot use up the server's owner cap", () => Effect.gen(function* () { - const batches = yield* sendForTenMinutes( + const batches = yield* sendFor( + 10, respondWithStatus(204), Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; @@ -170,6 +191,36 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { }), ); + it.effect("keeps a failed batch through a 20-minute outage and sends it once after", () => + Effect.gen(function* () { + const ingest = failingFor(20); + const batches = yield* sendFor(40, ingest.respond); + const statuses = ingest.answers.map(({ status }) => status); + assert.equal(statuses.at(-1), 204); + assert.equal(statuses.filter((status) => status === 204).length, 1); + const sentUuids = new Set(batches.map((batch) => batch.map(({ uuid }) => uuid).join())); + assert.equal(sentUuids.size, 1); + assert.equal(batches[0]?.length, 20); + }), + ); + + it.effect("drops a batch that keeps failing for 30 minutes", () => + Effect.gen(function* () { + const ingest = failingFor(Infinity); + yield* sendFor(60, ingest.respond); + const lastSend = ingest.answers.at(-1)?.at ?? 0; + assert.isAtLeast(lastSend, 30 * 60_000); + assert.isBelow(lastSend, 36 * 60_000); + }), + ); + + it.effect("drops a batch the endpoint rejects without sending it again", () => + Effect.gen(function* () { + const batches = yield* sendFor(10, respondWithStatus(400)); + assert.equal(batches.length, 1); + }), + ); + it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const capturedRequests: Array = []; diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 8f402b6682f2a..4edc6621b0c3e 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,8 +1,4 @@ -/** - * Events go to the T3 Code ingest endpoint, which keeps a copy and forwards - * them to PostHog. Each event carries a uuid, so PostHog can tell a retried - * copy from a new event. - */ +// T3 ingest retains events and forwards to PostHog; UUIDs deduplicate retries there. import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -36,35 +32,28 @@ interface BufferedAnalyticsEvent { interface DeliveryState { readonly failedBatch: ReadonlyArray; readonly batchAttempts: number; + readonly firstFailedAt: number; readonly failures: number; readonly retryAt: number; } -// The background flush checks for work at this interval, so a retry delay -// shorter than it waits for the next check. +// Retry delays shorter than this still wait for the next background check. const FLUSH_INTERVAL_MS = 10_000; // A hung send would hold the flush lock, and with it the shutdown flush. const SEND_TIMEOUT = "10 seconds"; -const MAX_BATCH_ATTEMPTS = 5; +// A failed batch is tried again until it has failed for this long. Ingest answers 503 when it +// cannot store a batch, and a broken Cloudflare location can take about 5 minutes to fail over. +// The waiting batch holds newer events back; `maxBufferedEvents` bounds them meanwhile. +const MAX_BATCH_RETRY_MS = 30 * 60_000; const RETRY_BASE_DELAY_MS = 2_000; // Clients can report any number of owner ids; past this many per observer, new ones // are not sent. Each observer has its own cap, so client reports never crowd out the // server's own. const MAX_RECORDED_OWNERS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; -// The endpoint answers these when the batch itself is wrong (bad body, wrong -// api_key, too large), so sending it again cannot help. 408 and 429 are not -// here because a later send can succeed. +// These statuses reject the batch itself; unlike 408/429, retrying cannot help. const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); -/** - * Release channel of this build. Release workflows set it for - * pingdotgg/t3code builds (see apps/server/vite.config.ts); every other build - * reports "dev". It is separate from `__T3CODE_BUILD_CHANNEL__`, which names - * stable "latest" and preview "nightly" for the sign-in page. - */ -declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; - /** `random` is in [0, 1). */ function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); @@ -106,16 +95,7 @@ export class AnalyticsService extends Context.Service< observedBy: "server" | "client", ) => Effect.Effect; } ->()("t3/telemetry/AnalyticsService") { - static readonly layerTest = Layer.succeed( - AnalyticsService, - AnalyticsService.of({ - record: () => Effect.void, - flush: Effect.void, - recordOwners: () => Effect.void, - }), - ); -} +>()("t3/telemetry/AnalyticsService") {} function serverOsFromNodePlatform(platform: string): ClientOs { switch (platform) { @@ -133,7 +113,7 @@ function serverOsFromNodePlatform(platform: string): ClientOs { } /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; @@ -144,6 +124,7 @@ export const make = Effect.gen(function* () { const deliveryRef = yield* Ref.make({ failedBatch: [], batchAttempts: 0, + firstFailedAt: 0, failures: 0, retryAt: 0, }); @@ -153,8 +134,6 @@ export const make = Effect.gen(function* () { // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const telemetryChannel = - typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; @@ -209,7 +188,7 @@ export const make = Effect.gen(function* () { wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), arch: hostArchitecture, t3CodeVersion: packageJson.version, - telemetryChannel, + telemetryChannel: ServerConfig.telemetryChannel, clientType, serverOs: serverOsFromNodePlatform(hostPlatform), serverArch: hostArchitecture, @@ -234,8 +213,7 @@ export const make = Effect.gen(function* () { return [nextBatch, current.slice(nextBatch.length)] as const; }); - // A rejected batch is dropped at once, but the next batch still waits for the - // backoff, so a wrong api_key does not send every batch as fast as it can. + // Back off even after dropping rejected batches so bad API keys cannot drain the queue in a tight loop. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { const delivery = yield* Ref.get(deliveryRef); @@ -246,27 +224,47 @@ export const make = Effect.gen(function* () { const sent = yield* Effect.result(sendBatch(batch)); if (Result.isSuccess(sent)) { - yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures: 0, retryAt: 0 }); + yield* Ref.set(deliveryRef, { + failedBatch: [], + batchAttempts: 0, + firstFailedAt: 0, + failures: 0, + retryAt: 0, + }); continue; } + const now = yield* Clock.currentTimeMillis; const failures = delivery.failures + 1; const batchAttempts = delivery.batchAttempts + 1; - const retryAt = (yield* Clock.currentTimeMillis) + retryDelayMs(failures, yield* Random.next); + const firstFailedAt = delivery.batchAttempts === 0 ? now : delivery.firstFailedAt; + const retryAt = now + retryDelayMs(failures, yield* Random.next); const error = sent.failure; const rejected = error._tag === "HttpClientError" && error.reason._tag === "StatusCodeError" && REJECTED_BATCH_STATUSES.has(error.reason.response.status); - if (!rejected && batchAttempts < MAX_BATCH_ATTEMPTS) { - yield* Ref.set(deliveryRef, { failedBatch: batch, batchAttempts, failures, retryAt }); + if (!rejected && now - firstFailedAt < MAX_BATCH_RETRY_MS) { + yield* Ref.set(deliveryRef, { + failedBatch: batch, + batchAttempts, + firstFailedAt, + failures, + retryAt, + }); yield* Effect.logDebug("Failed to send telemetry batch; will retry", { attempt: batchAttempts, cause: error, }); return; } - yield* Ref.set(deliveryRef, { failedBatch: [], batchAttempts: 0, failures, retryAt }); + yield* Ref.set(deliveryRef, { + failedBatch: [], + batchAttempts: 0, + firstFailedAt: 0, + failures, + retryAt, + }); yield* Effect.logWarning( rejected ? "Dropped telemetry batch the endpoint rejected" @@ -288,8 +286,7 @@ export const make = Effect.gen(function* () { function* (event, properties) { if (!telemetryConfig.enabled || !distinctId) return; - // Telemetry is best effort: an event without a uuid is not sent. The - // Node implementation throws (a defect) rather than failing, so catch both. + // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. const uuid = yield* Effect.exit(crypto.randomUUIDv7()); if (Exit.isFailure(uuid)) return; @@ -326,7 +323,7 @@ export const make = Effect.gen(function* () { ), ); - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.andThen(flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); @@ -336,5 +333,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(AnalyticsService, make); - -const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts index 724a74b5429b3..b3fb782ae8343 100644 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts @@ -1,14 +1,10 @@ /** - * Records one anonymous `mcp.delegated_task.finished` event when an app-owned - * delegated task reaches a final status, so we can see whether delegation - * between providers actually completes. The event carries the parent and child - * provider and model, the final status, and the child's runtime in seconds. + * Record one anonymous `mcp.delegated_task.finished` event per app-owned task reaching final status. * * @module DelegatedTaskAnalytics */ import type { OrchestrationV2DomainEvent } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; @@ -28,11 +24,6 @@ const FINAL_STATUSES: ReadonlySet = new Set([ "interrupted", ]); -export class DelegatedTaskAnalytics extends Context.Service< - DelegatedTaskAnalytics, - { readonly onSubagent: (event: SubagentEvent) => Effect.Effect } ->()("t3/telemetry/DelegatedTaskAnalytics") {} - const make = Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; const analytics = yield* AnalyticsService.AnalyticsService; @@ -89,8 +80,6 @@ const make = Effect.gen(function* () { event.type === "subagent.updated" ? onSubagent(event) : Effect.void, ), ); - - return DelegatedTaskAnalytics.of({ onSubagent }); }); -export const layer = Layer.effect(DelegatedTaskAnalytics, make); +export const layer = Layer.effectDiscard(make); diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts index 784eb670a20e6..9eac0b7ab7b5b 100644 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ b/apps/server/src/telemetry/FeatureUsage.test.ts @@ -67,3 +67,33 @@ it("reads RPC variants from enums, never from paths or text", () => { feature: "terminal.open", }); }); + +it("counts the owner's guest share actions, and only guest message sends", () => { + const send = (guestMessageId?: string) => + commandFeatureUsage( + command({ + ...ids, + type: "message.dispatch", + messageId: "message-private", + text: "secret prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + ...(guestMessageId === undefined ? {} : { guestMessageId }), + }), + ); + expect(send("guest-message-private")).toEqual({ feature: "guest_message.send" }); + expect(send()).toBe(undefined); + expect( + commandFeatureUsage( + command({ ...ids, type: "thread.guest-message.dismiss", guestMessageId: "guest-private" }), + ), + ).toEqual({ feature: "guest_message.dismiss" }); + expect(commandFeatureUsage(command({ ...ids, type: "thread.guest-share.close" }))).toEqual({ + feature: "guest_share.close", + }); + expect(featureUsage(WS_METHODS.guestShareCreate, { threadId: "thread-private" })).toEqual({ + feature: "guest_share.create", + }); +}); diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index 9c238c5f76972..61fb57ada454e 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -14,17 +14,16 @@ const use = (feature: string, variant?: string): FeatureUsage => variant === undefined ? { feature } : { feature, variant }; /** - * User commands sent through `orchestration.dispatchCommand` that represent a - * feature choice. Housekeeping commands (visits, reorders, acknowledgements) - * and `message.dispatch`, which `client.turn.requested` already counts, are - * left out. The dispatch handler records these, not the RPC middleware, so a - * retried command id that changed nothing is not counted again. + * Exclude housekeeping. `client.turn.requested` counts every `message.dispatch`; only sending a + * guest's message is also a feature. Record at command dispatch so retries do not count twice. */ const COMMAND_FEATURES: { readonly [Type in OrchestrationV2Command["type"]]?: ( command: Extract, - ) => FeatureUsage; + ) => FeatureUsage | undefined; } = { + "message.dispatch": (command) => + command.guestMessageId === undefined ? undefined : { feature: "guest_message.send" }, "checkpoint.rollback": (command) => ({ feature: "checkpoint.rollback", variant: command.restoreFiles === false ? "conversation_only" : "with_files", @@ -45,6 +44,8 @@ const COMMAND_FEATURES: { "run.interrupt": () => ({ feature: "run.interrupt" }), "queued-message.promote-to-steer": () => ({ feature: "queue.steer" }), "queued-run.edit": () => ({ feature: "queue.edit" }), + "thread.guest-share.close": () => ({ feature: "guest_share.close" }), + "thread.guest-message.dismiss": () => ({ feature: "guest_message.dismiss" }), }; /** @@ -69,14 +70,16 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.agentSessionsImport]: () => ({ feature: "agent_session.import" }), [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), + [WS_METHODS.sourceControlSignInStart]: () => ({ feature: "source_control.sign_in" }), [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }), + [WS_METHODS.guestShareCreate]: () => ({ feature: "guest_share.create" }), }; /** The feature a dispatched command uses, or undefined when it is not a tracked feature. */ export function commandFeatureUsage(command: OrchestrationV2Command): FeatureUsage | undefined { // TypeScript cannot pair a union's tag with its own entry, so widen once here. const mapping = COMMAND_FEATURES[command.type] as - | ((command: OrchestrationV2Command) => FeatureUsage) + | ((command: OrchestrationV2Command) => FeatureUsage | undefined) | undefined; return mapping?.(command); } diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 05e657adbe347..0baebc1e1803d 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -271,15 +271,10 @@ const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(fun }); /** - * `distinctId` keeps the rule every earlier build used, so analytics stay - * continuous: the hashed Codex `account_id`, else the Claude machine `userID`, - * else the install's anonymous id. It can change when someone logs in, and a - * Codex Team shares one. `installId` is the hashed anonymous id, which never - * changes for an install; count installs and people with it. - * - * A null `distinctId` turns telemetry off. A missing file falls through quietly, - * and so does an API-key-only Codex auth.json. Only unreadable or malformed - * files warn. + * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id. + * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install. + * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through; + * only unreadable or malformed files warn. */ export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* ( homeDirectory: string, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 49ee2db831a94..e30a4cd3d720d 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -5,7 +5,6 @@ import { type TerminalAttachStreamEvent, type TerminalEvent, type TerminalOpenInput, - type TerminalRestartInput, ProviderDriverKind, ProviderInstanceId, TerminalProviderInstanceNotFoundError, @@ -27,9 +26,6 @@ import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import { expect, vi } from "vite-plus/test"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import * as ServerConfig from "../config.ts"; -import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; @@ -207,17 +203,6 @@ function openInput(overrides: Partial = {}): TerminalOpenInpu }; } -function restartInput(overrides: Partial = {}): TerminalRestartInput { - return { - threadId: "thread-1", - terminalId: DEFAULT_TERMINAL_ID, - cwd: process.cwd(), - cols: 100, - rows: 24, - ...overrides, - }; -} - const historyLogPath = (logsDir: string, threadId = "thread-1") => Effect.service(Path.Path).pipe( Effect.map(({ join }) => join(logsDir, `terminal_${Base64Url.encode(threadId)}.log`)), @@ -457,6 +442,133 @@ it.layer( }), ); + // Which client owns the shared PTY size is left open by the issue, so this only asserts + // that every attached renderer is told the grid the PTY actually has. + it.effect("tells every attached stream the shared PTY size when attaches disagree", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const wideEvents: TerminalAttachStreamEvent[] = []; + const narrowEvents: TerminalAttachStreamEvent[] = []; + const stopWide = yield* manager.attachStream(openInput({ cols: 130, rows: 15 }), (event) => + Effect.sync(() => { + wideEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopNarrow = yield* manager.attachStream(openInput({ cols: 54, rows: 15 }), (event) => + Effect.sync(() => { + narrowEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopNarrow)); + + const spawned = ptyAdapter.spawnInputs[0]!; + const ptySize = ptyAdapter.processes[0]!.resizeCalls.at(-1) ?? { + cols: spawned.cols, + rows: spawned.rows, + }; + const lastReportedSize = (events: ReadonlyArray) => { + for (const event of events.toReversed()) { + const source: Record = + event.type === "snapshot" ? event.snapshot : event; + if (typeof source.cols === "number" && typeof source.rows === "number") { + return { cols: source.cols, rows: source.rows }; + } + } + return null; + }; + + expect({ + wide: lastReportedSize(wideEvents), + narrow: lastReportedSize(narrowEvents), + }).toEqual({ wide: ptySize, narrow: ptySize }); + }), + ); + + it.effect("gives the PTY back to the remaining viewer when the narrowest one detaches", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopNarrow = yield* manager.attachStream( + openInput({ cols: 54, rows: 15 }), + () => Effect.void, + undefined, + "narrow", + ); + const process = ptyAdapter.processes[0]!; + + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 80, rows: 12 }, + "narrow", + ); + // A resize from a client with no attached stream cannot override attached viewers. + yield* manager.resize({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 200, + rows: 50, + }); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 80, rows: 12 }); + + stopNarrow(); + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + }), + ); + + // Client retries resend the first attach's size, which a later resize may have replaced. + it.effect("keeps a viewer's resized grid when the same viewer attaches again", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const stopFirst = yield* manager.attachStream( + openInput({ cols: 54, rows: 15 }), + () => Effect.void, + undefined, + "phone", + ); + const process = ptyAdapter.processes[0]!; + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 100, rows: 15 }, + "phone", + ); + + const stopRetry = yield* manager.attachStream( + { ...openInput({ cols: 54, rows: 15 }), resume: true }, + () => Effect.void, + undefined, + "phone", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 100, rows: 15 }); + + stopRetry(); + stopFirst(); + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + it.effect("resumed attaches after close never respawn until the terminal is reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -492,14 +604,6 @@ it.layer( yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); expect((yield* Ref.get(attachEvents)).map((event) => event.type)).toEqual(["closed"]); expect(ptyAdapter.spawnInputs).toHaveLength(0); - - const restarted = yield* manager.restart(restartInput()); - expect(restarted.status).toBe("running"); - expect((yield* Ref.get(attachEvents)).map((event) => event.type)).toEqual([ - "closed", - "restarted", - ]); - expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); @@ -826,7 +930,7 @@ it.layer( "done\n"; // Counts reads of strings larger than one emitted chunk, so rescanning the - // pending prefix on every chunk (#23817) shows up as quadratic work. + // pending prefix on every chunk shows up as quadratic work. const charCodeAt = String.prototype.charCodeAt; let largeStringReads = 0; yield* Effect.gen(function* () { @@ -1328,85 +1432,6 @@ it.layer( }), ); - it.effect("restarts with current provider secrets and clears bounded history", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettings.ServerSettingsService; - const path = yield* Path.Path; - const providerInstanceId = ProviderInstanceId.make("codex_restart"); - const { manager, ptyAdapter, logsDir } = yield* createManager(2, { - historyByteLimit: 8, - resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => - TerminalManager.resolveProviderInstanceTerminalEnvironment({ - serverSettings, - path, - rawProviderInstanceId, - env, - }), - }); - const homePath = path.join(logsDir, "codex"); - const updateSecret = (value: string) => - serverSettings.updateSettings({ - providerInstances: { - [providerInstanceId]: { - driver: ProviderDriverKind.make("codex"), - config: { homePath }, - environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], - }, - }, - }); - const input = { - providerInstanceId, - env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, - }; - const outputProcessed = yield* Deferred.make(); - const unsubscribe = yield* manager.subscribe((event) => - event.type === "output" - ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) - : Effect.void, - ); - yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); - - yield* updateSecret("first-secret"); - yield* manager.restart(restartInput(input)); - const firstProcess = ptyAdapter.processes[0]!; - expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); - firstProcess.emitData("discarded\nold-one\nold-two\n"); - yield* Deferred.await(outputProcessed); - expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); - - yield* updateSecret("second-secret"); - const restarted = yield* manager.restart(restartInput(input)); - - expect(firstProcess.killed).toBe(true); - expect(ptyAdapter.spawnInputs).toHaveLength(2); - expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ - PROVIDER_SECRET: "second-secret", - CODEX_HOME: homePath, - CLIENT_FLAG: "1", - }); - expect(restarted.history).toBe(""); - expect(restarted.status).toBe("running"); - expect(restarted).not.toHaveProperty("env"); - expect(restarted).not.toHaveProperty("providerInstanceId"); - const logPath = yield* historyLogPath(logsDir); - expect(yield* readFileString(logPath)).toBe(""); - - ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); - yield* manager.close({ threadId: "thread-1" }); - expect(yield* readFileString(logPath)).toBe("new-two\n"); - }).pipe( - Effect.provide( - ServerSettings.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(SqlitePersistence.layerMemory), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), - ), - ), - ), - ), - ); - it.effect("attaches to a running provider terminal without resolving the provider again", () => Effect.gen(function* () { const providerInstanceId = ProviderInstanceId.make("codex_work"); @@ -1569,6 +1594,53 @@ it.layer( }).pipe(Effect.scoped), ); + it.effect.each([ + { recovered: true, label: "after recovery" }, + { recovered: false, label: "when cancelled before recovery" }, + ])("keeps a stalled viewer's size until its attach stream ends ($label)", ({ recovered }) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + const restored = yield* Deferred.make(); + const stopWide = yield* manager.attachStream( + openInput({ cols: 130, rows: 15 }), + (event) => + event.type === "activity" && event.cols === 130 && event.rows === 15 + ? Deferred.succeed(restored, undefined).pipe(Effect.asVoid) + : Effect.void, + undefined, + "wide", + ); + yield* Effect.addFinalizer(() => Effect.sync(stopWide)); + const process = ptyAdapter.processes[0]!; + + yield* Effect.gen(function* () { + const pull = yield* Stream.toPull( + manager.attachOutputStream(openInput({ cols: 54, rows: 15 }), "narrow"), + ); + expect(yield* pull).toMatchObject([{ type: "snapshot" }]); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 54, rows: 15 }); + // Overflow releases the inner subscription before any recovery pull. + yield* publishPastOutputBudget(manager, process); + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 50, rows: 12 }, + "narrow", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 50, rows: 12 }); + if (!recovered) return; + + expect(yield* pull).toMatchObject([{ type: "snapshot" }]); + yield* manager.resize( + { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cols: 48, rows: 12 }, + "narrow", + ); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 48, rows: 12 }); + }).pipe(Effect.scoped); + + yield* Deferred.await(restored); + expect(process.resizeCalls.at(-1)).toEqual({ cols: 130, rows: 15 }); + }).pipe(Effect.scoped), + ); + it.effect("does not replay in-flight output already included in an observer snapshot", () => 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 9f3c6387834cd..4c5229a7bae4f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -27,7 +27,6 @@ import { type TerminalObserveInput, type TerminalResizeInput, type ResourceMonitorProcessTableEntry, - type TerminalRestartInput, type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, @@ -58,6 +57,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; 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 ServerConfig from "../config.ts"; @@ -66,15 +66,12 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { - increment, - terminalRestartsTotal, - terminalSessionsTotal, -} from "../observability/Metrics.ts"; +import { increment, terminalSessionsTotal } from "../observability/Metrics.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts"; +import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; export { @@ -165,12 +162,27 @@ export class TerminalManager extends Context.Service< input: TerminalOpenInput, ) => Effect.Effect; + /** + * Attached viewers share one PTY sized to the smallest of them, so a narrow client + * still sees the whole TUI. Streams and resizes with the same `viewerId` (one client + * connection) count as one viewer; streams without one are each their own viewer. + */ readonly attachStream: ( input: TerminalAttachInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, registerCleanup?: (stop: () => void) => void, + viewerId?: string, ) => Effect.Effect<() => void, TerminalError>; + /** + * The `terminal.attach` RPC stream. Overflow recovery swaps the inner subscription, so + * `viewerId` and the size it reported stay attached until the whole stream ends. + */ + readonly attachOutputStream: ( + input: TerminalAttachInput, + viewerId: string, + ) => Stream.Stream; + readonly observeStream: ( input: TerminalObserveInput, listener: (event: TerminalAttachStreamEvent) => Effect.Effect, @@ -185,20 +197,14 @@ export class TerminalManager extends Context.Service< readonly write: (input: TerminalWriteInput) => Effect.Effect; - readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly resize: ( + input: TerminalResizeInput, + viewerId?: string, + ) => Effect.Effect; readonly clear: (input: TerminalClearInput) => Effect.Effect; - /** - * Always resets history before spawning the new process. - */ - readonly restart: ( - input: TerminalRestartInput, - ) => Effect.Effect; - - /** - * When `terminalId` is omitted, closes all sessions for the thread. - */ + /** Omit terminalId to close all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; readonly closeIdle: (input: { @@ -253,9 +259,11 @@ export interface ShellCandidate { args?: string[]; } -export interface TerminalStartInput extends TerminalOpenInput { - cols: number; - rows: number; +interface TerminalStartInput { + readonly cwd: string; + readonly worktreePath?: string | null; + readonly cols: number; + readonly rows: number; } interface TerminalSessionState { @@ -285,6 +293,17 @@ interface TerminalSessionState { runtimeEnv: Record | null; } +interface TerminalGrid { + readonly cols: number; + readonly rows: number; +} + +/** One client connection attached to a terminal, possibly through several streams. */ +interface TerminalViewer { + streams: number; + grid: TerminalGrid | null; +} + interface PersistHistoryRequest { history: BoundedTerminalHistory; immediate: boolean; @@ -366,6 +385,8 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { label: terminalWireLabel(session), updatedAt: session.updatedAt, sequence: session.eventSequence, + cols: session.cols, + rows: session.rows, }; } @@ -400,7 +421,7 @@ function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { } } -function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { +function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent { switch (event.type) { case "started": return { @@ -662,8 +683,7 @@ function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { - // `comm=` is the final column and may itself contain spaces, so only the - // first two tokens are structural. + // The final comm column can contain spaces; only the first two tokens are structural. const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); if (!match) continue; const pid = Number(match[1]); @@ -701,8 +721,7 @@ function deriveSubprocessInspectResult( const commandName = (pid: number) => normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); const shellName = commandName(terminalPid); - // Async prompt themes fork the shell into a helper that waits with no - // children of its own. That copy is not a command the user started. + // Async prompt themes fork childless shell helpers that are not user commands. const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( (pid) => shellName === null || @@ -733,9 +752,7 @@ function deriveSubprocessInspectResult( const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; -// Resolve `ps` to an absolute path once at startup. Spawning by bare name -// walks every PATH entry per spawn (one failed posix_spawn per directory -// until the hit), which is measurable at a 1s poll cadence on long PATHs. +// Resolve ps once; bare-name spawns repeatedly probe long PATHs during one-second polling. const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { const fileSystem = yield* FileSystem.FileSystem; for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { @@ -772,8 +789,7 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - // Not authoritative: an empty or partial table would mark every terminal - // idle and clear its registered process ids. Failing skips the tick. + // Reject partial tables; treating them as authoritative would clear process ids and mark terminals idle. return yield* new TerminalSubprocessCheckError({ command: "ps", exitCode: result.code, @@ -1031,7 +1047,6 @@ function neutralizeInheritedHistory(history: string, maxLines: number, maxBytes: // An unconditional 1049 reset restores a stale saved cursor on the main // screen. The exit must match the inherited screen's cursor-save behavior. let alternateScreen: number | undefined; - // eslint-disable-next-line no-control-regex -- Match replayed alternate-screen controls. for (const match of history.matchAll(/\u001b(?:\[\?([0-9;]+)([hl])|(c))/gu)) { if (match[3]) alternateScreen = undefined; for (const parameter of (match[1] ?? "").split(";")) { @@ -1062,10 +1077,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { return true; } - // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored - // query makes the terminal answer again, and the shell echoes the answer as - // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and - // DECSCL ("p) intact. + // Strip DECRQM/DECRPM query traffic to prevent echoed replies during replay; $ guards preserve setters. if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) { return true; } @@ -1080,9 +1092,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { return false; } -// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): -// pure request/response traffic with no visual value, and replaying a stored -// query triggers a fresh reply. +// Strip DECRQSS/XTGETTCAP query traffic; replaying queries triggers fresh replies without visual value. function shouldStripDcsSequence(content: string): boolean { return /^[01]?[$+][qr]/.test(content); } @@ -1134,7 +1144,6 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; } -// eslint-disable-next-line no-control-regex -- Terminal sequence introducers must reach the parser. const HISTORY_SEQUENCE_START = /[\u001b\u0090\u009b\u009d\u009e\u009f]/; const NEXT_HISTORY_SEQUENCE_START = new RegExp(HISTORY_SEQUENCE_START.source, "g"); @@ -1319,12 +1328,7 @@ function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { return segment === appDir || segment.startsWith(`${appDir}/`); } -// On Linux AppImage builds the runtime mounts the app under a temporary dir and -// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. -// The integrated terminal inherits the server process environment, so without -// this scrub those leak into the PTY and tools resolve against the AppImage -// mount instead of the user's real environment (e.g. `php` reporting PHP_BINARY -// as the AppImage path). See issue #1699. +// AppImage mount paths would make PTY tools resolve against the mount instead of the user's system. function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; @@ -1476,7 +1480,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; @@ -1524,14 +1528,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const agentScope = yield* AgentScope; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // Inherit the full environment minus createTerminalSpawnEnv's blocklist; allowlists silently drop user tooling variables. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( - function* ( + function* ( input: Input, ): Effect.fn.Return< Input, @@ -1548,9 +1550,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { ...input, env }; }, ); - // One process-table snapshot per poll tick, shared across every terminal. - // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and - // can exhaust the PID space on hosts with many sessions (#6332). + // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. const fallbackProcessTableSnapshot = ( platform === "win32" ? windowsProcessTableSnapshot() @@ -1664,6 +1664,104 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func effect: Effect.Effect, ): Effect.Effect => threadLocks.withLock(threadId, effect); + const terminalViewers = new Map>(); + let anonymousViewerCount = 0; + + const retainViewer = (sessionKey: string, viewerId: string, grid: TerminalGrid | null) => { + let viewers = terminalViewers.get(sessionKey); + if (!viewers) { + viewers = new Map(); + terminalViewers.set(sessionKey, viewers); + } + const viewer = viewers.get(viewerId); + if (viewer) { + viewer.streams += 1; + // After a viewer's first size, resizes own it; attach retries resend the original size. + viewer.grid ??= grid; + } else { + viewers.set(viewerId, { streams: 1, grid }); + } + }; + + /** Returns whether the viewer left, which can grow the shared grid. */ + const releaseViewer = (sessionKey: string, viewerId: string) => { + const viewers = terminalViewers.get(sessionKey); + const viewer = viewers?.get(viewerId); + if (!viewers || !viewer) return false; + viewer.streams -= 1; + if (viewer.streams > 0) return false; + viewers.delete(viewerId); + if (viewers.size === 0) terminalViewers.delete(sessionKey); + return true; + }; + + // The smallest attached viewer bounds each axis, so every viewer can draw the whole grid. + const sharedViewerGrid = (sessionKey: string): TerminalGrid | null => { + let shared: TerminalGrid | null = null; + for (const viewer of terminalViewers.get(sessionKey)?.values() ?? []) { + if (!viewer.grid) continue; + shared = shared + ? { + cols: Math.min(shared.cols, viewer.grid.cols), + rows: Math.min(shared.rows, viewer.grid.rows), + } + : viewer.grid; + } + return shared; + }; + + /** Attached viewers own the size; a caller's own size only applies when none has reported one. */ + const withSharedGrid = < + A extends { readonly cols?: number | undefined; readonly rows?: number | undefined }, + >( + sessionKey: string, + input: A, + ): A => { + const shared = sharedViewerGrid(sessionKey); + return shared ? { ...input, cols: shared.cols, rows: shared.rows } : input; + }; + + // Renderers draw the PTY grid, so every size change reaches them. It rides on activity + // because older clients reject unknown event types. + const resizeSession = Effect.fnUntraced(function* ( + session: TerminalSessionState, + process: PtyAdapter.PtyProcess, + cols: number, + rows: number, + ) { + yield* resizePtyProcess(session, process, cols, rows); + session.cols = cols; + session.rows = rows; + session.updatedAt = yield* nowIso; + const eventStamp = advanceEventSequence(session); + yield* publishEvent({ + type: "activity", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: eventStamp.sequence, + hasRunningSubprocess: session.hasRunningSubprocess, + label: terminalWireLabel(session), + cols, + rows, + }); + }); + + const applySharedGrid = Effect.fnUntraced(function* (threadId: string, terminalId: string) { + const session = yield* getSession(threadId, terminalId); + if (Option.isNone(session)) return; + const process = session.value.process; + const shared = sharedViewerGrid(toSessionKey(threadId, terminalId)); + if ( + !process || + session.value.status !== "running" || + !shared || + (shared.cols === session.value.cols && shared.rows === session.value.rows) + ) { + return; + } + yield* resizeSession(session.value, process, shared.cols, shared.rows); + }); + const clearKillFiber = Effect.fn("terminal.clearKillFiber")(function* ( process: PtyAdapter.PtyProcess | null, ) { @@ -1691,8 +1789,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func fiber: Fiber.Fiber, ) { yield* modifyManagerState((state) => { - // The child may finish (and run its finalizer) before this registration. - // Check inside the synchronized update so completed fibers are not retained. + // Children may finalize before registration; check inside the synchronized update to avoid retaining finished fibers. if (fiber.pollUnsafe() !== undefined) { return [undefined, state] as const; } @@ -1707,8 +1804,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: string, terminalId: string, ) { - // Subscribe before SIGTERM so an immediate exit is not missed. Released with - // Effect.ensuring because a JS `finally` does not run when the fiber is interrupted. + // Subscribe before SIGTERM to catch immediate exits; Effect.ensuring releases even on interruption, unlike JS finally. const exited = Deferred.makeUnsafe(); const unsubscribeExit = process.onExit(() => { Deferred.doneUnsafe(exited, Effect.void); @@ -2333,9 +2429,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func platform, yield* HostProcess.HomeDirectory, ); - // Append (never prepend) managed ACP agent install directories so - // `kimi login` and friends resolve by name without shadowing any - // system or user tool of the same name. + // Append managed install directories so agent commands resolve without shadowing user/system tools. if ( options.managedBinaryCacheDir !== undefined && options.managedBinaryToolsDir !== undefined @@ -2723,12 +2817,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* ( - input: TerminalOpenInput, + requestedInput: TerminalOpenInput, ) { - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); + const terminalId = requestedInput.terminalId; + yield* assertValidCwd(requestedInput.cwd); - const sessionKey = toSessionKey(input.threadId, terminalId); + const sessionKey = toSessionKey(requestedInput.threadId, terminalId); + const input = withSharedGrid(sessionKey, requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { yield* flushPersist(input.threadId, terminalId); @@ -2783,13 +2878,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* startSession( session, { - threadId: input.threadId, - terminalId, cwd: input.cwd, ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, - ...(input.env ? { env: input.env } : {}), }, "started", ); @@ -2835,13 +2927,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func yield* startSession( liveSession, { - threadId: input.threadId, - terminalId, cwd: input.cwd, worktreePath: liveSession.worktreePath, cols: targetCols, rows: targetRows, - ...(input.env ? { env: input.env } : {}), }, "started", ); @@ -2849,10 +2938,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - yield* resizePtyProcess(liveSession, liveSession.process, targetCols, targetRows); - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; + yield* resizeSession(liveSession, liveSession.process, targetCols, targetRows); } return snapshot(liveSession); @@ -2870,8 +2956,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = Effect.fnUntraced(function* (input: TerminalAttachInput) { - const terminalId = input.terminalId; + const openOrAttachForStream = Effect.fnUntraced(function* (requestedInput: TerminalAttachInput) { + const terminalId = requestedInput.terminalId; + const input = withSharedGrid(toSessionKey(requestedInput.threadId, terminalId), requestedInput); const existing = yield* getSession(input.threadId, terminalId); if (Option.isNone(existing)) { @@ -2911,11 +2998,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.status === "running" && (session.cols !== targetCols || session.rows !== targetRows) ) { - const process = session.process; - yield* resizePtyProcess(session, process, targetCols, targetRows); - session.cols = targetCols; - session.rows = targetRows; - session.updatedAt = yield* nowIso; + yield* resizeSession(session, session.process, targetCols, targetRows); } return snapshot(session); @@ -3000,7 +3083,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const attachEvent = terminalEventToAttachEvent(event); - return attachEvent ? listener(attachEvent) : Effect.void; + return listener(attachEvent); }, (cleanup) => { unsubscribe = cleanup; @@ -3022,9 +3105,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const attachEvent = terminalEventToAttachEvent(event); - if (attachEvent) { - yield* listener(attachEvent); - } + yield* listener(attachEvent); } // The live listener keeps this closure, so release replayed output. bufferedEvents.length = 0; @@ -3038,11 +3119,60 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; + /** Retains `viewer` and returns its release, which runs at most once. */ + const retainViewerOnce = ( + input: TerminalObserveInput, + viewer: string, + grid: TerminalGrid | null, + ) => { + const sessionKey = toSessionKey(input.threadId, input.terminalId); + retainViewer(sessionKey, viewer, grid); + let retained = true; + return () => { + if (!retained) return; + retained = false; + if (releaseViewer(sessionKey, viewer)) { + runFork(withThreadLock(input.threadId, applySharedGrid(input.threadId, input.terminalId))); + } + }; + }; + const attachStream: TerminalManager["Service"]["attachStream"] = ( input, listener, registerCleanup, - ) => streamSession(input, openOrAttachForStream(input), listener, registerCleanup); + viewerId, + ) => { + const viewer = viewerId ?? `stream:${++anonymousViewerCount}`; + let detach = () => {}; + const initial = Effect.suspend(() => { + detach = retainViewerOnce( + input, + viewer, + input.cols !== undefined && input.rows !== undefined + ? { cols: input.cols, rows: input.rows } + : null, + ); + return openOrAttachForStream(input); + }); + return streamSession( + input, + initial, + listener, + registerCleanup && + ((stop) => + registerCleanup(() => { + stop(); + detach(); + })), + ).pipe( + Effect.map((stop) => () => { + stop(); + detach(); + }), + Effect.onError(() => Effect.sync(detach)), + ); + }; const observeStream: TerminalManager["Service"]["observeStream"] = ( input, @@ -3070,6 +3200,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func registerCleanup, ); + const attachOutputStream: TerminalManager["Service"]["attachOutputStream"] = (input, viewerId) => + Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => retainViewerOnce(input, viewerId, null)), + (release) => Effect.sync(release), + ).pipe( + Effect.as( + terminalOutputStream( + (listener, registerCleanup) => attachStream(input, listener, registerCleanup, viewerId), + (listener, registerCleanup) => resyncStream(input, listener, registerCleanup), + ), + ), + ), + ); + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -3176,23 +3321,36 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); }); - const resizeLocked = Effect.fn("terminal.resize")(function* (input: TerminalResizeInput) { + const resizeLocked = Effect.fn("terminal.resize")(function* ( + input: TerminalResizeInput, + viewerId: string | undefined, + ) { + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const viewer = + viewerId === undefined ? undefined : terminalViewers.get(sessionKey)?.get(viewerId); + if (viewer) { + viewer.grid = { cols: input.cols, rows: input.rows }; + return yield* applySharedGrid(input.threadId, input.terminalId); + } + // A caller that is not attached cannot override the viewers that are. + if (sharedViewerGrid(sessionKey)) return; const session = yield* getSession(input.threadId, input.terminalId); if (Option.isNone(session)) { return; } const process = session.value.process; - if (!process || session.value.status !== "running") { + if ( + !process || + session.value.status !== "running" || + (session.value.cols === input.cols && session.value.rows === input.rows) + ) { return; } - yield* resizePtyProcess(session.value, process, input.cols, input.rows); - session.value.cols = input.cols; - session.value.rows = input.rows; - session.value.updatedAt = yield* nowIso; + yield* resizeSession(session.value, process, input.cols, input.rows); }); - const resize: TerminalManager["Service"]["resize"] = (input) => - withThreadLock(input.threadId, resizeLocked(input)); + const resize: TerminalManager["Service"]["resize"] = (input, viewerId) => + withThreadLock(input.threadId, resizeLocked(input, viewerId)); const clear: TerminalManager["Service"]["clear"] = (input) => withThreadLock( @@ -3216,99 +3374,6 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); - const restartResolved = Effect.fn("Manager.restartResolved")(function* ( - input: TerminalRestartInput, - ) { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - inputCount: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - closedSessionKeys.delete(sessionKey); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }); - - const restart: TerminalManager["Service"]["restart"] = (input) => - withThreadLock( - input.threadId, - resolveLaunchInputEnvironment(input).pipe( - Effect.flatMap((resolved) => - withWorkspaceLease( - path.resolve(resolved.worktreePath ?? resolved.cwd), - restartResolved(resolved), - ), - ), - ), - ); - const close: TerminalManager["Service"]["close"] = (input) => withThreadLock( input.threadId, @@ -3345,8 +3410,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const marks = new Map( running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); - // Inspect now instead of trusting the last poll, so a command started - // since then keeps its terminal. + // Inspect now so commands started after the last poll still protect their terminals. const { inspector } = yield* acquireSubprocessInspector; yield* Effect.forEach( running, @@ -3374,12 +3438,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return TerminalManager.of({ open, attachStream, + attachOutputStream, observeStream, resyncStream, write, resize, clear, - restart, close, closeIdle, subscribe, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 047c24ae8fc3d..4b83ddf6697e6 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -26,10 +26,7 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; -// node-pty stays external to the CLI bundle because it dlopens a native -// addon. Inside a Node single-executable, `import()` cannot load files from -// disk (only built-ins resolve), while `require` always reads the real -// filesystem, so both the module and its spawn-helper resolve through it. +// node-pty's native addon stays external; single-executables need require to resolve disk modules and spawn helpers. const requireForNodePty = NodeModule.createRequire(import.meta.url); const loadNodePty: NodePtyModuleLoader = () => @@ -81,12 +78,8 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); -/** - * node-pty defers creation to avoid blocking on named pipes: - * https://github.com/microsoft/node-pty/pull/885 Its public API has no - * readiness event. The private ready_datapipe handler sets pid before our - * listener runs. - */ +// node-pty has no public readiness event; ready_datapipe sets pid before our listener. +// Deferred pipe creation: https://github.com/microsoft/node-pty/pull/885 const waitForWindowsPid = ( process: import("node-pty").IPty, trackedProcess: NodePtyProcess, @@ -139,10 +132,7 @@ const waitForWindowsPid = ( return Effect.sync(cleanup); }); -/** - * Cancels Windows startup without waiting for the first output, unlike public kill(). - * The private agent can cancel the pending connection before a child exists. - */ +// The private agent cancels pending connections before a child exists; public kill waits for first output. const killStartingWindowsPty = (process: import("node-pty").IPty) => Effect.try(() => { if ( @@ -255,9 +245,7 @@ const make = Effect.fn("NodePtyAdapter.make")(function* () { return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - // node-pty only writes `name` into the child's TERM on the Unix path; - // the ConPTY path leaves the environment untouched, so Windows children - // inherit a missing or 16-color TERM unless it is set here. + // ConPTY ignores node-pty's name option for TERM; set it explicitly to avoid missing/16-color values. const env = platform === "win32" && input.env["TERM"] === undefined ? { ...input.env, TERM: "xterm-256color" } diff --git a/apps/server/src/terminal/OutputProtocol.test.ts b/apps/server/src/terminal/OutputProtocol.test.ts index a05d635361b81..a804a6610b774 100644 --- a/apps/server/src/terminal/OutputProtocol.test.ts +++ b/apps/server/src/terminal/OutputProtocol.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import { type TerminalAttachStreamEvent, TerminalAttachStreamEvent as TerminalAttachStreamEventSchema, - TerminalEvent as TerminalEventSchema, WS_METHODS, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -20,7 +19,6 @@ describe("terminal output window", () => { it.effect.each([ { tag: WS_METHODS.terminalAttach, size: 1, limit: 8 }, { tag: WS_METHODS.terminalObserve, size: 1, limit: 8 }, - { tag: WS_METHODS.subscribeTerminalEvents, size: 1, limit: 8 }, { tag: WS_METHODS.terminalAttach, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.terminalObserve, size: 64 * 1024, limit: 1 }, { tag: WS_METHODS.subscribeTerminalMetadata, size: 1, limit: 1 }, @@ -146,111 +144,101 @@ const chunkBytes = (message: RpcMessage.FromServerEncoded) => Buffer.byteLength(JSON.stringify(message)); describe("terminal output stream", () => { - it.effect.each([ - WS_METHODS.terminalAttach, - WS_METHODS.terminalObserve, - WS_METHODS.subscribeTerminalEvents, - ])("releases only the ACK-stalled subscriber on %s and resumes safely", (tag) => - Effect.gen(function* () { - const legacy = tag === WS_METHODS.subscribeTerminalEvents; - const group = RpcGroup.make( - Rpc.make(tag, { - success: legacy ? TerminalEventSchema : TerminalAttachStreamEventSchema, - stream: true, - }), - ); - const publisher = makePublisher(); - const stalled = yield* Queue.unbounded(); - const healthy = yield* Queue.unbounded(); - const receive = yield* Deferred.make[0]>(); - const protocol = yield* RpcServer.Protocol.make((write) => - Effect.gen(function* () { - yield* Deferred.succeed(receive, write); - const serialization = yield* RpcSerialization.RpcSerialization; - return { - disconnects: yield* Queue.unbounded(), - send: (clientId, response) => Queue.offer(clientId === 0 ? stalled : healthy, response), - end: () => Effect.void, - clientIds: Effect.succeed(new Set([0, 1])), - initialMessage: Effect.succeedNone, - supportsAck: true, - supportsTransferables: false, - supportsSpanPropagation: false, - supportsNotifications: true, - codecFor: serialization.codecFor, - }; - }), - ); - const initial = legacy ? output("ready") : snapshot(""); - yield* RpcServer.make(group).pipe( - Effect.provide( - group.toLayerHandler(tag, () => - terminalOutputStream( - (listener, registerCleanup) => - publisher - .subscribe(listener, registerCleanup) - .pipe(Effect.tap(() => listener(initial))), - legacy ? undefined : attach(publisher, "fresh history"), - 256 * 1024, + it.effect.each([WS_METHODS.terminalAttach, WS_METHODS.terminalObserve])( + "releases only the ACK-stalled subscriber on %s and resumes safely", + (tag) => + Effect.gen(function* () { + const group = RpcGroup.make( + Rpc.make(tag, { + success: TerminalAttachStreamEventSchema, + stream: true, + }), + ); + const publisher = makePublisher(); + const stalled = yield* Queue.unbounded(); + const healthy = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (clientId, response) => + Queue.offer(clientId === 0 ? stalled : healthy, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0, 1])), + initialMessage: Effect.succeedNone, + supportsAck: true, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + const initial = snapshot(""); + yield* RpcServer.make(group).pipe( + Effect.provide( + group.toLayerHandler(tag, () => + terminalOutputStream( + (listener, registerCleanup) => + publisher + .subscribe(listener, registerCleanup) + .pipe(Effect.tap(() => listener(initial))), + attach(publisher, "fresh history"), + 256 * 1024, + ), ), ), - ), - Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), - Effect.forkScoped, - ); - const write = yield* Deferred.await(receive); - const takeChunk = Effect.fnUntraced(function* (queue: typeof stalled) { - const response = yield* Queue.take(queue); - assert.equal(response._tag, "Chunk"); - if (response._tag !== "Chunk") throw new Error("Expected an RPC chunk"); - assert.isAtMost(chunkBytes(response), 70 * 1024); - return response.values; - }); - yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); - assert.deepEqual(yield* takeChunk(stalled), [initial]); - yield* write(1, { _tag: "Request", id: "2", tag, payload: null, headers: [] }); - assert.deepEqual(yield* takeChunk(healthy), [initial]); - yield* write(1, { _tag: "Ack", requestId: "2" }); - - // Eight unacknowledged chunks fill only client 0's output window. - for (let index = 0; index < 7; index++) { - const event = output("before-stall-" + index); - yield* publisher.publish(event); - assert.deepEqual(yield* takeChunk(stalled), [event]); - assert.deepEqual(yield* takeChunk(healthy), [event]); - yield* write(1, { _tag: "Ack", requestId: "2" }); - } - assert.equal(publisher.listeners.size, 2); - for (let index = 0; index < 100; index++) { - const event = output(index + ":" + "界".repeat(1365)); - yield* publisher.publish(event); - assert.deepEqual(yield* takeChunk(healthy), [event]); + Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + const takeChunk = Effect.fnUntraced(function* (queue: typeof stalled) { + const response = yield* Queue.take(queue); + assert.equal(response._tag, "Chunk"); + if (response._tag !== "Chunk") throw new Error("Expected an RPC chunk"); + assert.isAtMost(chunkBytes(response), 70 * 1024); + return response.values; + }); + yield* write(0, { _tag: "Request", id: "1", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(stalled), [initial]); + yield* write(1, { _tag: "Request", id: "2", tag, payload: null, headers: [] }); + assert.deepEqual(yield* takeChunk(healthy), [initial]); yield* write(1, { _tag: "Ack", requestId: "2" }); - if (index === 31) assert.equal(publisher.listeners.size, 2); - } - assert.equal(yield* Queue.size(stalled), 0); - assert.equal(publisher.listeners.size, 1); - yield* write(0, { _tag: "Ack", requestId: "1" }); - if (legacy) { - assert.deepEqual(yield* Queue.take(stalled), { - _tag: "Exit", - requestId: "1", - exit: { _tag: "Success", value: null }, - }); - } else { + // Eight unacknowledged chunks fill only client 0's output window. + for (let index = 0; index < 7; index++) { + const event = output("before-stall-" + index); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(stalled), [event]); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + } + assert.equal(publisher.listeners.size, 2); + for (let index = 0; index < 100; index++) { + const event = output(index + ":" + "界".repeat(1365)); + yield* publisher.publish(event); + assert.deepEqual(yield* takeChunk(healthy), [event]); + yield* write(1, { _tag: "Ack", requestId: "2" }); + if (index === 31) assert.equal(publisher.listeners.size, 2); + } + assert.equal(yield* Queue.size(stalled), 0); + assert.equal(publisher.listeners.size, 1); + + yield* write(0, { _tag: "Ack", requestId: "1" }); assert.deepEqual(yield* takeChunk(stalled), [snapshot("fresh history")]); assert.equal(publisher.listeners.size, 2); for (let index = 0; index < 7; index++) { yield* write(0, { _tag: "Ack", requestId: "1" }); } - } - const after = output("unique output after recovery"); - yield* publisher.publish(after); - assert.deepEqual(yield* takeChunk(healthy), [after]); - if (!legacy) assert.deepEqual(yield* takeChunk(stalled), [after]); - assert.equal(yield* Queue.size(stalled), 0); - }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + const after = output("unique output after recovery"); + yield* publisher.publish(after); + assert.deepEqual(yield* takeChunk(healthy), [after]); + assert.deepEqual(yield* takeChunk(stalled), [after]); + assert.equal(yield* Queue.size(stalled), 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), ); it.effect("interrupts recovery while its subscription is still waiting", () => diff --git a/apps/server/src/terminal/OutputProtocol.ts b/apps/server/src/terminal/OutputProtocol.ts index 5a0f596e84784..1a8e93381aae2 100644 --- a/apps/server/src/terminal/OutputProtocol.ts +++ b/apps/server/src/terminal/OutputProtocol.ts @@ -26,8 +26,7 @@ export function withTerminalOutputWindow( if ( message._tag === "Request" && (message.tag === WS_METHODS.terminalAttach || - message.tag === WS_METHODS.terminalObserve || - message.tag === WS_METHODS.subscribeTerminalEvents) + message.tag === WS_METHODS.terminalObserve) ) { const key = `${clientId}:${message.id}`; if (!windows.has(key)) windows.set(key, []); diff --git a/apps/server/src/terminal/nodePtyConsoleList.test.ts b/apps/server/src/terminal/nodePtyConsoleList.test.ts index be538491877c2..bcc8313089f43 100644 --- a/apps/server/src/terminal/nodePtyConsoleList.test.ts +++ b/apps/server/src/terminal/nodePtyConsoleList.test.ts @@ -3,9 +3,7 @@ import * as NodeModule from "node:module"; import { afterEach, describe, expect, it } from "vite-plus/test"; -// Exercises the patched console-list helper in patches/node-pty@1.2.0-beta.15.patch. -// The agent calls `require("child_process").fork` at call time, so replacing it on -// the shared CJS module stands in for the forked helper on every platform. +// The agent forks via the shared CJS module at call time; replace it to stub the helper on every platform. const requireCjs = NodeModule.createRequire(import.meta.url); const childProcess: typeof import("node:child_process") = requireCjs("child_process"); const { diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts index a8139563ff3bf..f2ad4a83e2d54 100644 --- a/apps/server/src/testUtils/gitConfig.setup.ts +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -1,8 +1,4 @@ -// Git for Windows installs with core.autocrlf=true, which checks committed LF -// files out as CRLF and breaks every byte-exact content assertion; a signing -// key or a non-default init branch on the developer's machine breaks fixtures -// the same way. Set as environment so each git child the driver spawns sees it -// without touching the fixtures. +// Override inherited autocrlf, signing and init-branch settings through env so every git child gets stable fixtures. const entries: ReadonlyArray = [ ["core.autocrlf", "false"], ["core.filemode", "false"], diff --git a/apps/server/src/testUtils/managedServiceEnv.setup.ts b/apps/server/src/testUtils/managedServiceEnv.setup.ts new file mode 100644 index 0000000000000..a6581817e14bb --- /dev/null +++ b/apps/server/src/testUtils/managedServiceEnv.setup.ts @@ -0,0 +1,6 @@ +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "../cloud/serviceProtocol.ts"; + +// Agents inside a managed T3 service inherit its home and launcher context; CI has neither. +for (const name of ["T3CODE_HOME", "T3_BOOT_SERVICE_UNIT", SERVICE_LAUNCHER_CONTEXT_ENV]) { + delete process.env[name]; +} diff --git a/apps/server/src/testUtils/shardWeights.json b/apps/server/src/testUtils/shardWeights.json index d0f1fafc69687..86df1db6d7399 100644 --- a/apps/server/src/testUtils/shardWeights.json +++ b/apps/server/src/testUtils/shardWeights.json @@ -17,7 +17,6 @@ "src/cli/service.test.ts": 0.5, "src/cli/theme.test.ts": 2.1, "src/cli/trace.test.ts": 1.2, - "src/cli/triage.test.ts": 0.5, "src/cli/uninstall.test.ts": 0.5, "src/cli/update.test.ts": 1.7, "src/cloud/CliTokenManager.test.ts": 0.7, @@ -203,6 +202,7 @@ "src/textGeneration/OpenCode2TextGeneration.test.ts": 1.4, "src/textGeneration/TextGeneration.test.ts": 1.2, "src/textGeneration/ThreadTitleLinks.test.ts": 0.5, + "src/triage/triageSession.test.ts": 0.5, "src/usage/UsageLimitSources.test.ts": 0.6, "src/usage/UsageService.test.ts": 2.2, "src/usage/cliproxyApi.test.ts": 0.5, diff --git a/apps/server/src/testUtils/weightedShardSequencer.ts b/apps/server/src/testUtils/weightedShardSequencer.ts index 6aed9892443d5..51c5130b3a5c4 100644 --- a/apps/server/src/testUtils/weightedShardSequencer.ts +++ b/apps/server/src/testUtils/weightedShardSequencer.ts @@ -6,21 +6,12 @@ import { BaseSequencer, type TestSpecification } from "vite-plus/test/node"; import shardWeights from "./shardWeights.json" with { type: "json" }; -// The median recorded file, for files added since the weights were recorded. +// Use the recorded median for new files. const UNRECORDED_FILE_SECONDS = 0.6; const recordedSeconds: Readonly> = shardWeights; -/** - * Splits server test files across `--shard` runs by recorded duration. Vitest's - * default split hashes file paths into equal-count shards, which can put the - * slowest files on one runner. Here the longest file goes to the lightest shard - * first. Every shard derives the same split from the same file list, so each - * file still runs in exactly one shard. Weights are each file's wall time in a - * CI shard, imports included: the gaps between consecutive file results in the - * `Test Server` logs. Imports are about a third of a shard, so test time alone - * leaves the shards with many small files slowest. - */ +// Include import time (~1/3 of CI shards) in weights; derive identical splits so each file runs once. export class WeightedShardSequencer extends BaseSequencer { override async shard(files: TestSpecification[]) { const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 }; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index b20e07e65e1b2..8fa9ccb1c8094 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -35,9 +35,9 @@ import { } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; -/** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ +/** `--tools` takes bare names; patterns go in the allow list. */ const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash"; -/** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */ +/** In dontAsk mode every call outside this list, Bash included, is refused. */ const CLAUDE_INVESTIGATION_ALLOWED = [ "Read", "Grep", @@ -46,10 +46,7 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ "Bash(git log:*)", "Bash(git show:*)", ].join(","); -/** - * The allowed git commands accept any trailing arguments, and `--output` makes each of them write - * a file anywhere. Deny rules win over allow rules, so this keeps the investigation read-only. - */ +// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); const ClaudeOutputEnvelope = Schema.Struct({ @@ -70,10 +67,7 @@ const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result const isClaudeErrorResult = Schema.is(ClaudeErrorResult); const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); -/** - * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only - * hold notices from a wrapper around the binary, so the caller prefers this when present. - */ +// Claude reports JSON errors on stdout; stderr may contain only wrapper notices. function claudeErrorResult(stdout: string): string | undefined { const output = Option.getOrUndefined(decodeJsonOption(stdout)); const messages: ReadonlyArray = Array.isArray(output) ? output : [output]; @@ -223,8 +217,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - // Claude refuses this flag next to a managed-mcp.json, which already keeps the - // user's own MCP servers out. + // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", "dontAsk", diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b57ed29988c76..a83e09a4110df 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -351,9 +351,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }, (textGeneration) => Effect.gen(function* () { - // Background fibers (e.g. the worktree branch rename fork) can run - // after their launching request's scope has closed; temp files must - // not be tied to that ambient scope or they are reaped on creation. + // Background fibers can outlive request scopes; temp files scoped there would be reaped on creation. const closedScope = yield* Scope.make(); yield* Scope.close(closedScope, Exit.void); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 20066232105f0..e17fa4c4c00e7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -30,9 +30,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const CODEX_TIMEOUT_MS = 180_000; const CODEX_MCP_LIST_TIMEOUT_MS = 10_000; -// Plugins and apps bring MCP servers of their own; the user's are listed and -// turned off by name below. These follow the user's launch args, so they win -// over a `-c features.plugins=true` there, which `--disable plugins` does not. +// Plugins/apps add MCP servers; use trailing config overrides because --disable loses to user -c flags. const CODEX_NO_PLUGIN_ARGS = [ "--config", "features.plugins=false", @@ -44,10 +42,7 @@ const CodexMcpServers = Schema.fromJsonString( ); const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers); -/** - * `mcp_servers={}` merges into the user's servers rather than replacing them, - * so each is named; quoting the name keeps one with a dot a single key. - */ +// mcp_servers={} merges instead of replacing; disable named entries and quote dotted names. const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null @@ -85,13 +80,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const removeTempFileDir = (filePath: string): Effect.Effect => - fileSystem - .remove(path.dirname(filePath), { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); + fileSystem.remove(path.dirname(filePath), { recursive: true }).pipe(Effect.ignore); - // Deliberately unscoped: text generation runs from background fibers whose - // ambient scope may already be closed (a closed scope reaps the temp - // directory the moment it is created). + // Background fibers may inherit closed scopes that immediately reap temp files; write unscoped. const writeTempFile = ( operation: string, prefix: string, @@ -290,9 +281,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func [ "exec", ...execLaunchArgs, - // Text generation needs only the prompt. A user's MCP server would - // otherwise start and could act on it, as Claude's --strict-mcp-config - // prevents for Claude. + // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, ...(mcpServersOff ? ["--config", mcpServersOff] : []), "--ephemeral", diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts index d45aad1d34300..5840b909acbd5 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts @@ -1,7 +1,4 @@ -/** - * (A deny-all session is refused on the free tier: the spike's - * `text_generation` recording.) - */ +/** The free tier refuses deny-all sessions. */ import type { ProviderReplayEntry } from "@t3tools/contracts"; export const OPENCODE2_TITLE_GENERATION: ReadonlyArray = [ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7e2d7bfebef41..96f266c6f8959 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -52,29 +52,35 @@ const resolveInstance = ( ), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return TextGeneration.of({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { + return resolveInstance( + registry, + "generateCommitMessage", + input.modelSelection.instanceId, + ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + }), + generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { + return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + ); + }), + generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { + return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), - ), - generateReviewGuide: (input) => - resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( + ); + }), + generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { + return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + ); + }), + generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { + return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => Effect.gen(function* () { const linkedContext = @@ -88,7 +94,8 @@ export const make = Effect.gen(function* () { return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); }), ), - ), + ); + }), }); }); diff --git a/apps/server/src/threadTask/ThreadTaskService.ts b/apps/server/src/threadTask/ThreadTaskService.ts index c48359eb69021..e21865d19cce8 100644 --- a/apps/server/src/threadTask/ThreadTaskService.ts +++ b/apps/server/src/threadTask/ThreadTaskService.ts @@ -222,10 +222,9 @@ export const make = Effect.gen(function* () { ); const requireShell = (threadId: ThreadId) => getShell(threadId).pipe( - Effect.flatMap((shell) => - shell === undefined - ? Effect.fail(fail("thread_not_found", `Thread ${threadId} was not found.`)) - : Effect.succeed(shell), + Effect.filterOrFail( + (shell) => shell !== undefined, + () => fail("thread_not_found", `Thread ${threadId} was not found.`), ), ); const put = (task: ThreadTask): Effect.Effect => @@ -1193,13 +1192,13 @@ export const make = Effect.gen(function* () { store.getLink(workerThreadId).pipe( Effect.catch(unavailable), Effect.map(Option.getOrUndefined), - Effect.flatMap((link) => - link !== undefined && - link.role === role && - link.peerEnvironmentId === peerEnvironmentId && - sameSecret(link.capability, capability) - ? Effect.succeed(link) - : Effect.fail(fail("scope_denied", "This task is not shared with that environment.")), + Effect.filterOrFail( + (link) => + link !== undefined && + link.role === role && + link.peerEnvironmentId === peerEnvironmentId && + sameSecret(link.capability, capability), + () => fail("scope_denied", "This task is not shared with that environment."), ), ); @@ -1543,13 +1542,13 @@ export const make = Effect.gen(function* () { }, (effect, workerThreadId) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread task delivery failed", { - workerThreadId, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread task delivery failed", { + workerThreadId, + cause: Cause.pretty(cause), + }), ), ), ); @@ -1756,13 +1755,13 @@ export const make = Effect.gen(function* () { }, (effect, stored) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread task reaction failed", { - threadId: stored.event.threadId, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread task reaction failed", { + threadId: stored.event.threadId, + cause: Cause.pretty(cause), + }), ), ), ); diff --git a/apps/server/src/threadTask/ThreadTaskStore.ts b/apps/server/src/threadTask/ThreadTaskStore.ts index 3ded019aa0c31..56342f85d36b9 100644 --- a/apps/server/src/threadTask/ThreadTaskStore.ts +++ b/apps/server/src/threadTask/ThreadTaskStore.ts @@ -142,7 +142,7 @@ const make = Effect.gen(function* () { let eventsSincePrune = 0; return ThreadTaskStore.of({ - getLink: (workerThreadId) => + getLink: Effect.fn("ThreadTaskStore.getLink")((workerThreadId) => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_task_links WHERE worker_thread_id = ${workerThreadId} `.pipe( @@ -150,6 +150,7 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeLinkRows("get-link")), Effect.map((links) => Option.fromNullishOr(links[0])), ), + ), putLink: Effect.fn("ThreadTaskStore.putLink")(function* (link) { const payload = yield* encodeLink(link).pipe(Effect.mapError(fail("encode-link"))); const pending = link.role === "worker" && link.pendingCursor > link.deliveredCursor ? 1 : 0; @@ -162,17 +163,19 @@ const make = Effect.gen(function* () { payload_json = excluded.payload_json `.pipe(Effect.mapError(fail("put-link"))); }), - listPendingLinks: () => + listPendingLinks: Effect.fn("ThreadTaskStore.listPendingLinks")(() => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_task_links WHERE role = 'worker' AND pending = 1 `.pipe( Effect.mapError(fail("list-pending-links")), Effect.flatMap(decodeLinkRows("list-pending-links")), ), - listLinks: () => + ), + listLinks: Effect.fn("ThreadTaskStore.listLinks")(() => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_task_links `.pipe(Effect.mapError(fail("list-links")), Effect.flatMap(decodeLinkRows("list-links"))), + ), appendEvent: Effect.fn("ThreadTaskStore.appendEvent")(function* (event) { yield* sql` INSERT INTO thread_task_events (at, worker_thread_id, kind, revision, detail) @@ -187,7 +190,7 @@ const make = Effect.gen(function* () { `.pipe(Effect.mapError(fail("prune-events"))); } }), - getSettleRequest: (threadId) => + getSettleRequest: Effect.fn("ThreadTaskStore.getSettleRequest")((threadId) => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_settle_requests WHERE thread_id = ${threadId} `.pipe( @@ -195,13 +198,15 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeSettleRows("get-settle-request")), Effect.map((requests) => Option.fromNullishOr(requests[0])), ), - listPendingSettleRequests: () => + ), + listPendingSettleRequests: Effect.fn("ThreadTaskStore.listPendingSettleRequests")(() => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_settle_requests WHERE state = 'pending' `.pipe( Effect.mapError(fail("list-settle-requests")), Effect.flatMap(decodeSettleRows("list-settle-requests")), ), + ), putSettleRequest: Effect.fn("ThreadTaskStore.putSettleRequest")(function* (request) { const payload = yield* encodeSettleRequest(request).pipe(Effect.mapError(fail("encode"))); yield* sql` @@ -212,7 +217,7 @@ const make = Effect.gen(function* () { payload_json = excluded.payload_json `.pipe(Effect.mapError(fail("put-settle-request"))); }), - get: (workerThreadId) => + get: Effect.fn("ThreadTaskStore.get")((workerThreadId) => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_tasks WHERE worker_thread_id = ${workerThreadId} `.pipe( @@ -220,7 +225,8 @@ const make = Effect.gen(function* () { Effect.flatMap(decodeRows("get")), Effect.map((tasks) => Option.fromNullishOr(tasks[0])), ), - listVisible: ({ threadId, afterCursor }) => + ), + listVisible: Effect.fn("ThreadTaskStore.listVisible")(({ threadId, afterCursor }) => (threadId === null ? sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_tasks @@ -233,7 +239,8 @@ const make = Effect.gen(function* () { ORDER BY cursor ` ).pipe(Effect.mapError(fail("list-visible")), Effect.flatMap(decodeRows("list-visible"))), - listByProjectKey: ({ projectKey, limit }) => + ), + listByProjectKey: Effect.fn("ThreadTaskStore.listByProjectKey")(({ projectKey, limit }) => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_tasks WHERE json_extract(payload_json, '$.projectKey') = ${projectKey} @@ -243,12 +250,14 @@ const make = Effect.gen(function* () { Effect.mapError(fail("list-by-project-key")), Effect.flatMap(decodeRows("list-by-project-key")), ), - listByOwner: (ownerThreadId) => + ), + listByOwner: Effect.fn("ThreadTaskStore.listByOwner")((ownerThreadId) => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_tasks WHERE owner_thread_id = ${ownerThreadId} ORDER BY cursor `.pipe(Effect.mapError(fail("list-by-owner")), Effect.flatMap(decodeRows("list-by-owner"))), - listUnfinished: () => + ), + listUnfinished: Effect.fn("ThreadTaskStore.listUnfinished")(() => sql<{ readonly payload: string }>` SELECT payload_json AS payload FROM thread_tasks WHERE json_extract(payload_json, '$.wake.state') = 'pending' @@ -258,6 +267,7 @@ const make = Effect.gen(function* () { Effect.mapError(fail("list-unfinished")), Effect.flatMap(decodeRows("list-unfinished")), ), + ), latestCursor, put: Effect.fn("ThreadTaskStore.put")(function* (task) { const cursor = (yield* latestCursor) + 1; diff --git a/apps/server/src/threadTask/ThreadTaskTransport.ts b/apps/server/src/threadTask/ThreadTaskTransport.ts index a4ee97497cdee..6e1ad61d0da9c 100644 --- a/apps/server/src/threadTask/ThreadTaskTransport.ts +++ b/apps/server/src/threadTask/ThreadTaskTransport.ts @@ -36,10 +36,11 @@ export const layerFromBroker = Layer.effect( const descriptor = yield* (yield* ServerEnvironment.ServerEnvironment).getDescriptor; return ThreadTaskTransport.of({ localEnvironmentId: descriptor.environmentId, - call: (environmentId, request) => + call: Effect.fn("ThreadTaskTransport.call")((environmentId, request) => broker .call("threadTask", [environmentId, request]) .pipe(Effect.mapError((error) => error.message)), + ), peers: () => Object.keys(broker.grants()) .filter((id) => id !== descriptor.environmentId) diff --git a/apps/server/src/todo/TodoCommands.ts b/apps/server/src/todo/TodoCommands.ts index aa29a0b0ade70..1757142dda451 100644 --- a/apps/server/src/todo/TodoCommands.ts +++ b/apps/server/src/todo/TodoCommands.ts @@ -52,14 +52,8 @@ const TodoCommandRejectionJson = Schema.fromJsonString(TodoCommandRejection); export const encodeTodoCommandRejection = Schema.encodeSync(TodoCommandRejectionJson); export const decodeTodoCommandRejection = Schema.decodeUnknownOption(TodoCommandRejectionJson); -/** - * Decide one todo command against the todo's row (including a removed one). - * Every accepted command produces exactly one event. - * - * Settle and unsettle follow thread settlement: repeating either re-emits the - * current state (original `settledAt`, unchanged `updatedAt`), so double - * clicks and agent retries are silent no-ops rather than errors. - */ +/** Every accepted command emits one event, including commands against removed rows. + * Repeated settle/unsettle re-emits settledAt and updatedAt unchanged for silent retries. */ export function planTodoCommand(input: { readonly command: TodoCommand; readonly todo: TodoRow | undefined; diff --git a/apps/server/src/todo/TodoService.ts b/apps/server/src/todo/TodoService.ts index d0d5bbf1c07e6..b76e2fe2f2367 100644 --- a/apps/server/src/todo/TodoService.ts +++ b/apps/server/src/todo/TodoService.ts @@ -303,8 +303,8 @@ const make = Effect.gen(function* () { }); return toTodo(yield* readTodo(todoId)); }), - settle: (input) => mutate("todo.settle", input), - unsettle: (input) => mutate("todo.unsettle", input), + settle: Effect.fn("TodoService.settle")((input) => mutate("todo.settle", input)), + unsettle: Effect.fn("TodoService.unsettle")((input) => mutate("todo.unsettle", input)), remove: Effect.fn("TodoService.remove")(function* (input) { const todoId = yield* commit({ type: "todo.delete", diff --git a/apps/server/src/triage/TriageService.test.ts b/apps/server/src/triage/TriageService.test.ts new file mode 100644 index 0000000000000..d3ffc8c91d03e --- /dev/null +++ b/apps/server/src/triage/TriageService.test.ts @@ -0,0 +1,162 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { GitCommandError } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import { ChildProcessSpawner } from "effect/process"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as TriageService from "./TriageService.ts"; +import { pickSourceRef } from "./TriageService.ts"; + +const MAIN = "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe\trefs/heads/main"; + +it("clones the server's release tag, peeling annotated tags to their commit", () => { + assert.deepEqual(pickSourceRef(`${MAIN}\n7027b1f\trefs/tags/v0.1.1\n`, "0.1.1"), { + ref: "v0.1.1", + commit: "7027b1f", + }); + assert.deepEqual( + pickSourceRef(`${MAIN}\n1111111\trefs/tags/v0.1.1\n2222222\trefs/tags/v0.1.1^{}\n`, "0.1.1"), + { ref: "v0.1.1", commit: "2222222" }, + ); +}); + +it("falls back to main when the server's version has no tag", () => { + assert.deepEqual(pickSourceRef(`${MAIN}\n`, "0.1.2-dev"), { + ref: "main", + commit: "a1dc856ee61278d0199c36d4bcdef5fdea4d21fe", + }); + assert.isUndefined(pickSourceRef("", "0.1.1")); +}); + +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-triage-service-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const RELEASE_COMMIT = "7027b1f"; + +/** The service over a stub git that answers each command with `answer`. */ +const makeTriage = ( + answer: (command: string) => Effect.Effect = (command) => + Effect.succeed( + command === "ls-remote" + ? `${RELEASE_COMMIT}\trefs/tags/v${packageJson.version}\n` + : command === "rev-parse" + ? `${RELEASE_COMMIT}\n` + : "", + ), +) => + Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const commands: string[] = []; + const triage = yield* Layer.build(TriageService.layer).pipe( + Effect.map(Context.get(TriageService.TriageService)), + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => { + const command = input.args[0] ?? ""; + commands.push(command); + return answer(command).pipe( + Effect.map((stdout) => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + })), + ); + }, + }), + ); + return { triage, commands }; + }); + +const readContext = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const sessions = `${config.stateDir}/triage`; + const [session] = yield* fs.readDirectory(sessions); + return yield* fs.readFileString(`${sessions}/${session}/context.md`); +}); + +it.effect("sessions started during a clone share it, and a release tag is looked up once", () => + Effect.gen(function* () { + const fetches = yield* Queue.unbounded(); + const { triage, commands } = yield* makeTriage((command) => + command === "fetch" + ? Queue.offer(fetches, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed( + command === "ls-remote" ? `${RELEASE_COMMIT}\trefs/tags/v${packageJson.version}\n` : "", + ), + ); + + yield* triage.prepare({ client: {} }); + yield* triage.prepare({ client: {} }); + yield* Queue.take(fetches); + + assert.deepEqual( + commands.filter((command) => command === "ls-remote" || command === "init"), + ["ls-remote", "init"], + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("reuses a finished clone without fetching", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(`${config.baseDir}/source/${RELEASE_COMMIT}/.git`, { + recursive: true, + }); + const { triage, commands } = yield* makeTriage(); + + yield* triage.prepare({ client: {} }); + + assert.deepEqual(commands, ["ls-remote", "rev-parse"]); + assert.include(yield* readContext, "The server cloned the source for its own version"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("still starts triage when the source lookup fails", () => + Effect.gen(function* () { + const { triage } = yield* makeTriage(() => + Effect.fail( + new GitCommandError({ + operation: "TriageService.resolveSourceRef", + command: "git ls-remote", + cwd: "/", + detail: "Could not resolve host: github.com", + }), + ), + ); + + const { prompt } = yield* triage.prepare({ client: {} }); + + assert.include(prompt, "triage-playbook.md"); + assert.include(yield* readContext, "Could not resolve host: github.com"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("does not trust a folder without its own .git, even inside a repo at that commit", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(`${config.baseDir}/source/${RELEASE_COMMIT}`, { recursive: true }); + // The stub answers rev-parse with the commit, as a parent repository could. + const { triage, commands } = yield* makeTriage(); + + yield* triage.prepare({ client: {} }); + + assert.notInclude(commands, "rev-parse"); + assert.include(yield* readContext, "is not a complete checkout"); + }).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/triage/TriageService.ts b/apps/server/src/triage/TriageService.ts new file mode 100644 index 0000000000000..f7a93f24a6641 --- /dev/null +++ b/apps/server/src/triage/TriageService.ts @@ -0,0 +1,216 @@ +import { + type AuthClientPresentationMetadata, + ServerPrepareTriageError, + type ServerPrepareTriageInput, + type ServerPrepareTriageResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { buildTriageAppPrompt, type TriageSourceClone } from "./triagePrompt.ts"; +import { triageSourceCacheDir, writeTriageSession } from "./triageSession.ts"; + +const T3CODE_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; + +// No tty: a credential prompt would hang until the timeout, so git must fail +// instead. An empty GIT_ASKPASS also skips core.askPass and SSH_ASKPASS helpers. +const GIT_ENV = { + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + LC_ALL: "C", +} satisfies NodeJS.ProcessEnv; + +/** Starts in-app triage sessions: the same files `t3 triage` writes, plus a source clone. */ +export class TriageService extends Context.Service< + TriageService, + { + readonly prepare: ( + input: ServerPrepareTriageInput, + ) => Effect.Effect; + } +>()("t3/triage/TriageService") {} + +const describeClient = (client: AuthClientPresentationMetadata) => + [ + client.label ?? "an unknown client", + client.appVersion === undefined ? undefined : `version ${client.appVersion}`, + client.os === undefined ? undefined : `on ${client.os}`, + client.browser === undefined ? undefined : `in ${client.browser}`, + client.webDeployment === "hosted" ? "(app.t3.codes)" : undefined, + ] + .filter((part) => part !== undefined) + .join(" "); + +/** Picks the release tag for `version`, or `main` when the build has no tag. */ +export const pickSourceRef = (lsRemoteOutput: string, version: string) => { + const commits = new Map( + lsRemoteOutput + .split("\n") + .map((line) => line.trim().split("\t")) + .filter((fields): fields is [string, string] => fields.length === 2) + .map(([commit, ref]) => [ref, commit]), + ); + const tag = `v${version}`; + // Annotated tags list the tag object first and the commit as `^{}`. + const tagCommit = commits.get(`refs/tags/${tag}^{}`) ?? commits.get(`refs/tags/${tag}`); + if (tagCommit !== undefined) return { ref: tag, commit: tagCommit }; + const mainCommit = commits.get("refs/heads/main"); + return mainCommit === undefined ? undefined : { ref: "main", commit: mainCommit }; +}; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = triageSourceCacheDir(config.baseDir, path); + // Beside the cache, not in it: the playbook has agents prune the cache, and a + // clone in progress must not look like a stale entry. Same filesystem, so the + // finished clone moves in with a rename. + const stagingDir = `${sourceDir}-staging`; + const serverVersion = packageJson.version; + + const runGit = (operation: string, cwd: string, args: ReadonlyArray, timeoutMs: number) => + git.execute({ operation: `TriageService.${operation}`, cwd, args, timeoutMs, env: GIT_ENV }); + + // Clones outlive the request that started them: a fresh clone takes tens of + // seconds, and the user is writing their description meanwhile. + const serviceScope = yield* Effect.scope; + // Clones a previous server process left half done. + yield* fs.remove(stagingDir, { recursive: true }).pipe(Effect.ignore); + + // Fetches the exact commit `ls-remote` resolved, so the folder the session + // names is the one that appears even when main moves meanwhile. + const cloneSource = Effect.fn("TriageService.cloneSource")(function* (commit: string) { + yield* fs.makeDirectory(stagingDir, { recursive: true }); + const staging = yield* fs.makeTempDirectory({ directory: stagingDir, prefix: "clone-" }); + const directory = path.join(sourceDir, commit); + yield* Effect.gen(function* () { + yield* runGit("initSource", staging, ["init", "--quiet"], 10_000); + yield* runGit( + "addSourceRemote", + staging, + ["remote", "add", "origin", T3CODE_REPOSITORY_URL], + 10_000, + ); + yield* runGit( + "fetchSource", + staging, + ["fetch", "--depth", "1", "--filter=blob:none", "origin", commit], + 600_000, + ); + yield* runGit("checkoutSource", staging, ["checkout", "--quiet", "FETCH_HEAD"], 600_000); + yield* fs.rename(staging, directory).pipe( + // A concurrent session cloned the same commit first; keep theirs. + Effect.catch((error) => + fs + .exists(directory) + .pipe(Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error)))), + ), + ); + }).pipe( + // Gone after a successful rename; otherwise a failed or losing clone. + Effect.ensuring(fs.remove(staging, { recursive: true }).pipe(Effect.ignore)), + ); + }); + + // Release tags never move, so one lookup serves every later session. Builds + // with no tag track main, which does move, so they look it up each time. + let releaseSource: { readonly ref: string; readonly commit: string } | undefined; + const resolveSource = Effect.fn("TriageService.resolveSource")(function* () { + if (releaseSource !== undefined) return releaseSource; + const tag = `refs/tags/v${serverVersion}`; + const listed = yield* runGit( + "resolveSourceRef", + sourceDir, + ["ls-remote", T3CODE_REPOSITORY_URL, tag, `${tag}^{}`, "refs/heads/main"], + // Triage still starts without source; a dead network should not stall it. + 5_000, + ); + const source = pickSourceRef(listed.stdout, serverVersion); + if (source?.ref !== "main") releaseSource = source; + return source; + }); + + // Commits with a clone running, so sessions started meanwhile share it. + const cloning = new Set(); + + // The playbook's own layout: one folder per commit under the source cache, so + // the agent finds and reuses this clone instead of making another. The folder + // appears only once the clone is complete. + const startSourceClone = Effect.fn("TriageService.startSourceClone")(function* () { + yield* fs.makeDirectory(sourceDir, { recursive: true }); + const source = yield* resolveSource(); + if (source === undefined) { + return { + _tag: "failed", + serverVersion, + reason: "the repository listed neither the release tag nor main.", + } satisfies TriageSourceClone; + } + const directory = path.join(sourceDir, source.commit); + if (yield* fs.exists(directory)) { + // Our clones appear only when complete, but an agent may have cloned + // here by hand and stopped partway. + // Without its own .git, rev-parse would read a parent repository. + const head = (yield* fs.exists(path.join(directory, ".git"))) + ? yield* runGit("checkSource", directory, ["rev-parse", "HEAD"], 10_000).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.orElseSucceed(() => ""), + ) + : ""; + return head === source.commit + ? ({ _tag: "cloned", directory, serverVersion, ...source } as const) + : ({ + _tag: "failed", + serverVersion, + reason: `${directory} exists but is not a complete checkout of ${source.commit}. Remove it first.`, + } satisfies TriageSourceClone); + } + if (!cloning.has(source.commit)) { + cloning.add(source.commit); + yield* cloneSource(source.commit).pipe( + Effect.ignoreCause({ log: true }), + Effect.ensuring(Effect.sync(() => cloning.delete(source.commit))), + Effect.forkIn(serviceScope), + ); + } + return { _tag: "cloning", directory, serverVersion, ...source } as const; + }); + + const prepare: TriageService["Service"]["prepare"] = Effect.fn("TriageService.prepare")( + function* (input) { + const sourceClone = yield* startSourceClone().pipe( + Effect.catch((error) => + Effect.succeed({ + _tag: "failed", + serverVersion, + reason: error.message, + }), + ), + ); + const session = yield* writeTriageSession({ + baseDir: config.baseDir, + paths: config, + entry: { kind: "app", client: describeClient(input.client) }, + server: `running (pid ${String(process.pid)}, version ${serverVersion}); this session runs inside it`, + sourceClone, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new ServerPrepareTriageError({ cause })), + ); + return { prompt: buildTriageAppPrompt(session.promptFilePath) }; + }, + ); + + return TriageService.of({ prepare }); +}); + +export const layer = Layer.effect(TriageService, make); diff --git a/apps/server/src/triage/triagePrompt.test.ts b/apps/server/src/triage/triagePrompt.test.ts new file mode 100644 index 0000000000000..e034ad107fecd --- /dev/null +++ b/apps/server/src/triage/triagePrompt.test.ts @@ -0,0 +1,82 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; + +import { + buildTriageContext, + buildTriageLaunchPrompt, + type TriageContextInput, + TRIAGE_PLAYBOOK, +} from "./triagePrompt.ts"; + +it("stays byte-identical to .github/triage/PLAYBOOK.md", () => { + // Older releases fetch the repository playbook. + const canonicalPath = NodePath.join( + import.meta.dirname, + "../../../../.github/triage/PLAYBOOK.md", + ); + assert.equal(TRIAGE_PLAYBOOK, NodeFS.readFileSync(canonicalPath, "utf8")); +}); + +it("launch prompt stays a single argv-safe line naming the prompt file", () => { + // Windows .cmd shims cannot carry multiline argv. + const launch = buildTriageLaunchPrompt(String.raw`C:\Users\a b\.t3\userdata\triage\x\prompt.md`); + assert.notInclude(launch, "\n"); + assert.include(launch, String.raw`C:\Users\a b\.t3\userdata\triage\x\prompt.md`); + assert.isBelow(launch.length, 1_000); +}); + +it("context file labels the CLI version as the CLI's, not the user's build", () => { + const context = buildTriageContext({ + ...contextInput, + entry: { kind: "cli", cliVersion: "0.0.42", launchedAs: "npx t3 triage" }, + server: "running (pid 42, http://127.0.0.1:3773, version 0.0.43-nightly.20260923.2173)", + }); + assert.notInclude(context, "Installed version"); + assert.notInclude(context, "Release tag"); + assert.include(context, "- Triage CLI version: 0.0.42 ("); + assert.include(context, "version 0.0.43-nightly.20260923.2173"); + assert.notInclude(context, "runs inside T3 Code"); +}); + +it("in-app context warns the agent off its own server and points at the clone in progress", () => { + const context = buildTriageContext({ + ...contextInput, + entry: { kind: "app", client: "T3 Code Desktop version 0.1.1 on macos" }, + server: "running (pid 42, version 0.1.1); this session runs inside it", + sourceClone: { + _tag: "cloning", + directory: "/home/u/.t3/source/abc123", + ref: "v0.1.1", + commit: "abc123", + serverVersion: "0.1.1", + }, + }); + assert.notInclude(context, "Triage CLI version"); + assert.include(context, "- Started from: T3 Code Desktop version 0.1.1 on macos ("); + assert.include(context, "## This session runs inside T3 Code"); + assert.include(context, "/home/u/.t3/source/abc123 (v0.1.1, commit abc123)"); + assert.include(context, "Wait for it instead of\ncloning again."); +}); + +const contextInput = { + generatedAt: "2026-09-24T00:00:00.000Z", + os: "linux x64 (7.2.0)", + nodeVersion: "v26.8.2", + paths: { + stateDir: "/home/u/.t3/userdata", + dbPath: "/home/u/.t3/userdata/state.sqlite", + settingsPath: "/home/u/.t3/userdata/settings.json", + logsDir: "/home/u/.t3/userdata/logs", + serviceLogPath: "/home/u/.t3/userdata/logs/boot-service.log", + desktopBackendLogGlob: "/home/u/.t3/userdata/logs/server-child*.log*", + serverTracePath: "/home/u/.t3/userdata/logs/server.trace.ndjson", + providerEventLogPath: "/home/u/.t3/userdata/logs/provider/events.log", + terminalLogsDir: "/home/u/.t3/userdata/logs/terminals", + providerStatusCacheDir: "/home/u/.t3/caches", + secretsDir: "/home/u/.t3/userdata/secrets", + sourceCacheDir: "/home/u/.t3/source", + }, +} satisfies Omit; diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/triage/triagePrompt.ts similarity index 67% rename from apps/server/src/cli/triagePrompt.ts rename to apps/server/src/triage/triagePrompt.ts index 2e27d96b45607..5c937abdb8959 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/triage/triagePrompt.ts @@ -1,7 +1,6 @@ -/** - * `TRIAGE_PLAYBOOK` must stay byte-identical to `.github/triage/PLAYBOOK.md` - * (only backticks and backslashes are escaped here). - */ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** Keep TRIAGE_PLAYBOOK byte-identical to .github/triage/PLAYBOOK.md after unescaping backticks and backslashes. */ export const TRIAGE_PLAYBOOK = `# T3 Code triage playbook @@ -16,8 +15,8 @@ provided alongside this playbook. Everything machine-specific lives there, not h ## 1. Ask what went wrong -Your first message to the user: ask them to describe what went wrong, in their own -words. Ask them to paste screenshots directly into this session if they have any. +Unless the user already described it, your first message asks what went wrong, in +their own words. Ask them to paste screenshots directly into this session if they have any. Ask follow-up questions when the description is vague. Good repro steps are the most valuable thing you can extract from this conversation. @@ -150,34 +149,61 @@ user's environment and evidence instead of filing a new issue. A confirmed duplicate with fresh evidence is more useful than a second thread. `; -/** - * The one-line argument the agent session is launched with. The real - * instructions live in `prompt.md` on disk: Windows `.cmd` shims run through - * cmd.exe, which cannot carry a multiline, multi-kilobyte argv string. - */ +/** Where the session started: the `t3 triage` CLI, or "Triage an issue" in a client. */ +export type TriageEntry = + | { readonly kind: "cli"; readonly cliVersion: string; readonly launchedAs: string } + | { readonly kind: "app"; readonly client: string }; + +/** Windows .cmd shims cannot carry a multiline playbook in argv; point to the file instead. */ export const buildTriageLaunchPrompt = (promptFilePath: string) => `Read the file "${promptFilePath}" and follow its instructions exactly: it is your T3 Code triage playbook, and it starts with asking the user what went wrong.`; -export const buildTriageSeedPrompt = (contextFilePath: string) => `A T3 Code user is \ -having a problem with their install and started this session with \`t3 triage\`. +/** + * The composer text an in-app session opens with. The file link renders as a + * chip, and the user writes what went wrong after it, so the first turn + * already carries the description. + */ +export const buildTriageAppPrompt = (promptFilePath: string) => + `Read ${serializeComposerFileLink(promptFilePath)} and follow it. `; + +export const buildTriageSeedPrompt = (input: { + readonly contextFilePath: string; + readonly entry: TriageEntry; +}) => `A T3 Code user is having a problem with their install and started this \ +session ${input.entry.kind === "cli" ? "with `t3 triage`" : "from the T3 Code app"}. Machine facts (versions, OS, paths, server liveness) are in the triage context file: - ${contextFilePath} + ${input.contextFilePath} -Follow the playbook below, starting by asking the user what went wrong. +${ + input.entry.kind === "cli" + ? "Follow the playbook below, starting by asking the user what went wrong." + : "Follow the playbook below. The message that sent you here may already describe what went wrong; if it does not, start by asking." +} --- ${TRIAGE_PLAYBOOK}`; +/** A clone the server made before the session started, or why it could not. */ +export type TriageSourceClone = + | { + readonly _tag: "cloned" | "cloning"; + readonly directory: string; + readonly ref: string; + readonly commit: string; + readonly serverVersion: string; + } + | { readonly _tag: "failed"; readonly serverVersion: string; readonly reason: string }; + export interface TriageContextInput { readonly generatedAt: string; - readonly cliVersion: string; + readonly entry: TriageEntry; readonly os: string; readonly nodeVersion: string; - readonly launchedAs: string; readonly server: string; + readonly sourceClone?: TriageSourceClone | undefined; readonly paths: { readonly stateDir: string; readonly dbPath: string; @@ -194,17 +220,74 @@ export interface TriageContextInput { }; } -export const buildTriageContext = (input: TriageContextInput) => `# T3 Code triage context +const entryLines = (entry: TriageEntry) => + entry.kind === "cli" + ? { + generatedBy: "`t3 triage`", + before: `- Triage CLI version: ${entry.cliVersion} (the \`t3\` that ran this command; the bug may have happened on another build)`, + after: `- CLI launched as: ${entry.launchedAs}`, + } + : { + generatedBy: "the T3 Code app", + before: `- Started from: ${entry.client} (the client the user pressed "Triage an issue" in; the bug may have happened on another device)`, + after: undefined, + }; + +const sourceSection = (clone: TriageSourceClone) => { + if (clone._tag === "failed") { + return ` +## Source + +The server could not clone the source for its version (${clone.serverVersion}): ${clone.reason} +Clone it as step 4 describes. +`; + } + const verb = clone._tag === "cloned" ? "The server cloned" : "The server is cloning"; + const what = + clone.ref === "main" + ? `main, since its version (${clone.serverVersion}) has no release tag` + : `the source for its own version (${clone.serverVersion})`; + const wait = + clone._tag === "cloned" + ? "" + : ` +The folder appears only when the clone is complete. Wait for it instead of +cloning again. If it has not appeared after a few minutes, clone as step 4 +describes. +`; + return ` +## Source + +${verb} ${what}: + + ${clone.directory} (${clone.ref}, commit ${clone.commit}) +${wait} +Use it when the bug happened on this server's build. When it happened on another +build, clone that version as step 4 describes. +`; +}; -Generated by \`t3 triage\` at ${input.generatedAt}. +const IN_APP_SECTION = ` +## This session runs inside T3 Code -- Triage CLI version: ${input.cliVersion} (the \`t3\` that ran this command; the bug may have happened on another build) +You are a thread inside the T3 Code server described above. Stopping, +restarting, or killing that server, its service, or the desktop app ends this +session. When a fix needs one of those, give the user the exact steps and let +them do it. The database is in use by that server: read it, never lock it. +`; + +export const buildTriageContext = (input: TriageContextInput) => { + const entry = entryLines(input.entry); + return `# T3 Code triage context + +Generated by ${entry.generatedBy} at ${input.generatedAt}. + +${entry.before} - OS: ${input.os} - Node: ${input.nodeVersion} -- CLI launched as: ${input.launchedAs} -- Server process: ${input.server} +${entry.after === undefined ? "" : `${entry.after}\n`}- Server process: ${input.server} - Repo: https://github.com/pingdotgg/t3code - +${input.entry.kind === "app" ? IN_APP_SECTION : ""}${input.sourceClone === undefined ? "" : sourceSection(input.sourceClone)} ## Paths - State dir: ${input.paths.stateDir} @@ -220,3 +303,4 @@ Generated by \`t3 triage\` at ${input.generatedAt}. - Secrets dir (NEVER read this): ${input.paths.secretsDir} - Source cache dir (clone the repo here): ${input.paths.sourceCacheDir} `; +}; diff --git a/apps/server/src/cli/triage.test.ts b/apps/server/src/triage/triageSession.test.ts similarity index 95% rename from apps/server/src/cli/triage.test.ts rename to apps/server/src/triage/triageSession.test.ts index 7443c2425ac6c..6826ebd16bf1e 100644 --- a/apps/server/src/cli/triage.test.ts +++ b/apps/server/src/triage/triageSession.test.ts @@ -9,7 +9,7 @@ import { makePersistedServerRuntimeState, persistServerRuntimeState, } from "../serverRuntimeState.ts"; -import { describeServerProcess } from "./triage.ts"; +import { describeServerProcess } from "./triageSession.ts"; it.effect("reports the version the running server recorded, not the CLI's", () => Effect.gen(function* () { diff --git a/apps/server/src/triage/triageSession.ts b/apps/server/src/triage/triageSession.ts new file mode 100644 index 0000000000000..14654cf7d28cc --- /dev/null +++ b/apps/server/src/triage/triageSession.ts @@ -0,0 +1,100 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; + +import { TRIAGE_PROMPT_FILE_NAME } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; +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 BootService from "../cloud/bootService.ts"; +import type * as ServerConfig from "../config.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { + buildTriageContext, + buildTriageSeedPrompt, + type TriageEntry, + type TriageSourceClone, +} from "./triagePrompt.ts"; + +export const describeServerProcess = Effect.fn("triage.describeServerProcess")(function* ( + serverRuntimeStatePath: string, +) { + const state = yield* readPersistedServerRuntimeState(serverRuntimeStatePath); + if (Option.isNone(state)) { + return "not running (no server-runtime.json; the server may never have started here)"; + } + const version = + state.value.serverVersion === undefined + ? "version not recorded" + : `version ${state.value.serverVersion}`; + if (!isProcessAlive(state.value.pid)) { + return `not running (state file is stale: pid ${String(state.value.pid)} is dead; last origin ${state.value.origin}, ${version})`; + } + return `running (pid ${String(state.value.pid)}, ${state.value.origin}, ${version})`; +}); + +/** The source cache the playbook clones into, one subdirectory per commit. */ +export const triageSourceCacheDir = (baseDir: string, path: Path.Path) => + path.join(baseDir, "source"); + +/** + * Writes `context.md` and the playbook prompt into a fresh `/triage/(effect: Effect.Effect) => + effect.pipe( + Effect.retry({ + while: (error) => error.reason === "config_locked", + times: 10, + schedule: Schedule.spaced("50 millis"), + }), + ); + + const writeConfigValue: GitVcsDriver.GitVcsDriver["Service"]["writeConfigValue"] = Effect.fn( + "writeConfigValue", + )(function* (input) { + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.writeConfigValue", + input.cwd, + ["config", input.key, input.value], + { writesSharedConfig: true }, + ).pipe(retryWhileConfigLocked); + }); + + // `git branch` writes a remote start point's upstream itself and fails outright if the + // config is locked, so tracking is recorded afterwards by a write that retries. + const createBranchAt = Effect.fn("createBranchAt")(function* ( + operation: string, + input: GitVcsDriver.GitCreateBranchInput, + fallbackErrorDetail: string, + ) { + // `git branch` does not read a lone - as the previous checkout. + const startPoint = input.startPoint === "-" ? "@{-1}" : input.startPoint; + yield* executeGitWithStableDiagnostics( + operation, + input.cwd, + ["branch", "--no-track", "--", input.branch, startPoint], + { fallbackErrorDetail }, + ); + const startRef = yield* executeGit( + `${operation}.resolveStartPoint`, + input.cwd, + ["rev-parse", "--symbolic-full-name", startPoint], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() : ""))); + if (!startRef.startsWith("refs/remotes/")) return; + yield* executeGitWithStableDiagnostics( + `${operation}.track`, + input.cwd, + ["branch", `--set-upstream-to=${startRef}`, "--", input.branch], + { writesSharedConfig: true }, + ).pipe( + retryWhileConfigLocked, + Effect.catch((cause) => + Effect.logWarning("created the branch without tracking its start point", { cause }), + ), + ); + }); + + const createBranch: GitVcsDriver.GitVcsDriver["Service"]["createBranch"] = Effect.fn( + "createBranch", + )(function* (input) { + yield* refuseOptionLikeRefName("GitVcsDriver.createBranch", input.cwd, input.startPoint); + yield* createBranchAt("GitVcsDriver.createBranch", input, "git branch create failed"); + }); + const readBranchBase: GitVcsDriver.GitVcsDriver["Service"]["readBranchBase"] = Effect.fn( "GitVcsDriver.readBranchBase", )(function* (cwd, branch) { @@ -3665,10 +3703,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); const snapshot = refresh || currentEpoch === undefined - ? // The refresh cache owns the complete snapshot read, rather than only the - // epoch bump. Slow repositories therefore remain singleflight for the - // entire Git scan even when more refresh requests arrive after the - // coalescing TTL would otherwise have elapsed. + ? // Cache the entire snapshot read so slow scans stay single-flight beyond the coalescing TTL. yield* Cache.get( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ gitCommonDir, generation }), @@ -3776,7 +3811,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); - // resolve: a cwd like `/sub/..` must not name the folder `..`. + // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; if (worktreePath == null) { @@ -3791,14 +3826,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.createWorktree", command: "git worktree add", cwd: input.cwd, - detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Storage.`, + 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); } - const args = input.newRefName - ? ["worktree", "add", "-b", input.newRefName, "--", worktreePath, input.refName] - : ["worktree", "add", "--", worktreePath, input.refName]; + if (input.newRefName) { + // Kept apart from the checkout so the shared-config lock only covers the tracking write. + yield* createBranchAt( + "GitVcsDriver.createWorktree.createBranch", + { cwd: input.cwd, branch: input.newRefName, startPoint: input.refName }, + "git worktree add failed", + ); + } + const args = ["worktree", "add", "--", worktreePath, input.newRefName ?? input.refName]; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -3830,9 +3871,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onWorktreeClaimed(worktreePath); } - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. + // worktree add leaves submodules empty, potentially omitting project skills, tools or source. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); @@ -3865,19 +3904,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onSubmodulesStarted(); } const onSubmoduleLine = progress?.onSubmoduleLine; + // Registering submodule URLs writes the shared config; the clones that follow do not. yield* runGit( - "GitVcsDriver.createWorktree.updateSubmodules", + "GitVcsDriver.createWorktree.initSubmodules", worktreePath, - submoduleMode.value === "recursive" - ? ["submodule", "update", "--init", "--recursive"] - : ["submodule", "update", "--init"], - onSubmoduleLine - ? { - env: { LC_ALL: "C" }, - progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, - } - : {}, + ["submodule", "init"], + { writesSharedConfig: true }, ).pipe( + Effect.andThen( + runGit( + "GitVcsDriver.createWorktree.updateSubmodules", + worktreePath, + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], + onSubmoduleLine + ? { + env: { LC_ALL: "C" }, + progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + } + : {}, + ), + ), Effect.matchEffect({ onFailure: (cause) => Effect.logWarning("worktree submodule checkout failed; submodule paths are empty", { @@ -3903,20 +3951,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const base = yield* resolveBranchBase(input.cwd, input.baseRefName); if (base) { yield* Effect.gen(function* () { - yield* writeConfigValue( - "GitVcsDriver.createWorktree.configureBaseRef", - input.cwd, - branchBaseConfigKey(newRefName), - base.branchName, - ); + yield* writeConfigValue({ + cwd: input.cwd, + key: branchBaseConfigKey(newRefName), + value: base.branchName, + }); if (base.baseRef.startsWith("refs/remotes/")) { // The base key names a branch on the host; this one keeps the remote it came from. - yield* writeConfigValue( - "GitVcsDriver.createWorktree.configureComparisonRef", - input.cwd, - `branch.${input.newRefName}.t3-merge-base-ref`, - base.baseRef, - ); + yield* writeConfigValue({ + cwd: input.cwd, + key: `branch.${input.newRefName}.t3-merge-base-ref`, + value: base.baseRef, + }); } }).pipe( Effect.catchTags({ @@ -4010,17 +4056,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["merge-base", "--is-ancestor", headCommit, input.targetCommit], { allowNonZeroExit: true }, ).pipe(Effect.map((result) => result.exitCode === 0)); - // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can - // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD - // never left the commit the upstream held before the fetch. + // Rewritten heads require reset; allow only clean checkouts still at the pre-fetch upstream commit. if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { return { headCommit, moved: false, onTarget: false }; } if (!isAncestor) { - // The commit being reset away is about to be reachable from nothing. It is only ever a - // commit the remote already held, but "the remote held it" stops being a way back once - // the head it belonged to has been rewritten, so a ref keeps it findable. + // Retain the reset-away commit; rewritten remote heads no longer provide a path back. yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", input.cwd, @@ -4032,10 +4074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.move", input.cwd, - // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another - // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` - // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the - // other branch — so a race loses nothing; the refresh fails and is reported instead. + // reset --merge refuses tracked edits racing our cleanliness check, preserving work like --ff-only does. isAncestor ? ["merge", "--ff-only", input.targetCommit] : ["reset", "--merge", input.targetCommit], @@ -4068,8 +4107,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); return run.pipe( - Effect.flatMap((result) => - result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), + Effect.filterOrElse( + (result) => !(result.exitCode !== 0 && isRefLockFailure(result.stderr)), + (_result) => run, ), ); }; @@ -4209,12 +4249,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); const targetRef = `${input.remoteName}/${input.remoteBranch}`; + if (!localBranchAlreadyExists) { + return yield* createBranchAt( + "GitVcsDriver.fetchRemoteBranch.materialize", + { cwd: input.cwd, branch: input.localBranch, startPoint: targetRef }, + "git branch create failed", + ); + } yield* runGit( "GitVcsDriver.fetchRemoteBranch.materialize", input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", "--", input.localBranch, targetRef] - : ["branch", "--", input.localBranch, targetRef], + ["branch", "--force", "--", input.localBranch, targetRef], + { writesSharedConfig: true }, ); }); @@ -4231,13 +4277,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => - runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ - "branch", - "--set-upstream-to", - `${input.remoteName}/${input.remoteBranch}`, - "--", - input.branch, - ]); + executeGitWithStableDiagnostics( + "GitVcsDriver.setBranchUpstream", + input.cwd, + [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + "--", + input.branch, + ], + { writesSharedConfig: true }, + ).pipe(retryWhileConfigLocked, Effect.asVoid); /** * Unlinks links under `target` without following them and removes the @@ -4283,12 +4334,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { - // Git for Windows never descends into NTFS junctions, such as pnpm's - // node_modules links. It reports success but leaves them and their parent - // directories behind, so a resumed thread would find a stub instead of - // recreating its checkout. Git has already unregistered the worktree, so - // anything left here is logged rather than returned: a retry could never - // succeed. + // Git for Windows reports success without removing NTFS junctions or their parent directories. + // Log leftover cleanup failures: Git already unregistered the worktree, so removal cannot be retried. if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))) { const removed = yield* removeLeftoverLinks(target).pipe( Effect.catch((error) => @@ -4307,9 +4354,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } return; } - // Threads can share a worktree path, and worktrees get removed or pruned - // outside the app, so a worktree that is already gone is a no-op rather - // than an error. + // Threads share paths and outside Git can remove worktrees; already-gone removals are no-ops. const alreadyGone = isMissingWorktreeStderr(result.stderr) && !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); @@ -4352,6 +4397,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { timeoutMs: 10_000, fallbackErrorDetail: "git branch delete failed", + // Under contention Git deletes the ref but keeps its config section. + writesSharedConfig: true, }, ); }); @@ -4366,18 +4413,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? input.newBranch : yield* resolveAvailableBranchName(input.cwd, input.newBranch); - // Renaming also moves the branch's config section, and Git reports a - // config lock only after it already renamed the ref. - yield* gitConfigWriters.withPermits(1)( - executeGit( - "GitVcsDriver.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorDetail: "git branch rename failed", - }, - ), + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorDetail: "git branch rename failed", + writesSharedConfig: true, + }, ); return { branch: targetBranch }; @@ -4462,6 +4506,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { timeoutMs: 10_000, fallbackErrorDetail: "git checkout failed", + // Checking out a remote branch creates a local one that tracks it. + writesSharedConfig: !localInputExists, }, ); @@ -4494,6 +4540,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", "--", input.refName], { timeoutMs: 10_000, fallbackErrorDetail: "git branch create failed", + // `branch.autoSetupMerge=always` records HEAD's branch as the upstream. + writesSharedConfig: true, }); if (input.switchRef) { yield* switchRef({ cwd: input.cwd, refName: input.refName }); @@ -4516,7 +4564,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "branch", "--list", "--no-column", - "--format=%(refname:short)", + "--format=%(refname:lstrip=2)", ]).pipe( Effect.map((stdout) => { const branchNames: Array = []; @@ -4532,8 +4580,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe( - // One record per worktree, each ended by an empty field. A `prunable` - // record's directory is gone, and another checkout may now sit there. + // A `prunable` record's directory is gone; another checkout may now sit there. Effect.map(({ stdout }) => stdout.split("\0\0").flatMap((record) => { const fields = record.split("\0"); @@ -4571,6 +4618,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // Forwarders retain the underlying Git operation spans. return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -4578,11 +4626,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetailsLocal, statusDetailsRemote, prepareCommitContext, - commit: (cwd, subject, body, options) => - withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), - pushCurrentBranch: (cwd, fallbackBranch, options) => - withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), - pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + commit: Effect.fnUntraced(function* (cwd, subject, body, options) { + return yield* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)); + }), + pushCurrentBranch: Effect.fnUntraced(function* (cwd, fallbackBranch, options) { + return yield* withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)); + }), + pullCurrentBranch: Effect.fnUntraced(function* (cwd) { + return yield* withListRefsInvalidation(cwd, pullCurrentBranch(cwd)); + }), readRangeContext, getReviewDiffPreview, getReviewDiffFileContents, @@ -4590,18 +4642,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* readBranchBase, resolveBranchName, listRefs, - createWorktree: (input, options) => - withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchRefIntoBranch: (input) => withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)), + createWorktree: Effect.fnUntraced(function* (input, options) { + return yield* withListRefsInvalidation(input.cwd, createWorktree(input, options)); + }), + fetchRefIntoBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)); + }), fetchRefCommit, resolveCommit, - refreshCheckedOutBranch: (input) => - withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), - ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), + refreshCheckedOutBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)); + }), + ensureRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, ensureRemote(input)); + }), resolvePrimaryRemoteName, resolveDefaultBranchName, - fetchRemote: (input) => - withListRefsInvalidation( + fetchRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation( input.cwd, fetchRemote(input).pipe( Effect.flatMap((fetched) => @@ -4610,24 +4668,49 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : fetchRemote({ cwd: input.cwd, remoteName: input.remoteName }).pipe(Effect.asVoid), ), ), - ), + ); + }), remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, resolveWorktreeBaseRemote, - fetchWorktreeBaseCommit: (input) => - withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)), - fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), - fetchRemoteTrackingBranch: (input) => - withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), - setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), - removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), - pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), - deleteLocalBranch: (input) => withListRefsInvalidation(input.cwd, deleteLocalBranch(input)), - renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), - createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), - applyPatch: (input) => withListRefsInvalidation(input.cwd, applyPatch(input)), - switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + fetchWorktreeBaseCommit: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)); + }), + fetchRemoteBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)); + }), + fetchRemoteTrackingBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)); + }), + setBranchUpstream: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, setBranchUpstream(input)); + }), + createBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, createBranch(input)); + }), + writeConfigValue, + removeWorktree: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, removeWorktree(input)); + }), + pruneWorktrees: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, pruneWorktrees(input)); + }), + deleteLocalBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, deleteLocalBranch(input)); + }), + renameBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, renameBranch(input)); + }), + createRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, createRef(input)); + }), + applyPatch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, applyPatch(input)); + }), + switchRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, switchRef(input)); + }), initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, listWorktreePaths, diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 4ecc5237da207..b382922c5ab27 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -2,13 +2,10 @@ import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type { - VcsDriverCapabilities, VcsError, VcsInitInput, VcsListRemotesResult, VcsListWorkspaceFilesResult, - ReviewDiffPreviewInput, - ReviewDiffPreviewResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -71,11 +68,10 @@ export interface VcsCheckpointOps { export class VcsDriver extends Context.Service< VcsDriver, { - readonly capabilities: VcsDriverCapabilities; readonly execute: ( input: Omit, ) => Effect.Effect; - readonly checkpoints?: VcsCheckpointOps; + readonly checkpoints: VcsCheckpointOps; readonly detectRepository: ( cwd: string, ) => Effect.Effect; @@ -89,8 +85,5 @@ export class VcsDriver extends Context.Service< relativePaths: ReadonlyArray, ) => Effect.Effect, VcsError>; readonly initRepository: (input: VcsInitInput) => Effect.Effect; - readonly getDiffPreview?: ( - input: ReviewDiffPreviewInput, - ) => Effect.Effect; } >()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index b0273c96da07e..d42cae5cc3478 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -14,9 +14,7 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; -// Status polls detect every open cwd each tick, and detection costs three git -// processes. `detect` re-checks each hit against the disk, so this TTL only -// bounds rare changes those stats cannot see. +// Detection costs three git processes per cwd; cached hits still stat disk, so TTL bounds only undetectable changes. const DETECTION_CACHE_TTL = Duration.minutes(5); export interface VcsDriverResolveInput { @@ -53,12 +51,6 @@ function parseDetectionCacheKey(key: string): { readonly requestedKind: VcsDriverKind | "auto"; } { const separatorIndex = key.indexOf("\0"); - if (separatorIndex === -1) { - return { - cwd: key, - requestedKind: "auto", - }; - } return { requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", cwd: key.slice(separatorIndex + 1), @@ -118,8 +110,7 @@ export const make = Effect.gen(function* () { return yield* detectWithDriver("git", git, input.cwd); }); - // Falls back to the cwd alone when the root is not above it, such as a cwd - // reached through a symlink. + // Symlinks can put cwd outside root; then inspect cwd alone. const foldersBelowRoot = (cwd: string, rootPath: string) => { const relative = path.relative(path.resolve(rootPath), path.resolve(cwd)); if (relative.startsWith("..") || path.isAbsolute(relative)) return [cwd]; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 980f4c7e674b4..e2055e3400531 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -94,14 +94,10 @@ const gitMetricOperations = new Set([ ]); /** - * Appends `key=value` to the `GIT_CONFIG_*` entries git reads as command-line - * config, so argv stays the same on every platform and nothing is written to - * the user's config. The entry goes after any inherited ones. Windows env names - * are case-insensitive (elsewhere git reads only `GIT_CONFIG_COUNT`), so there the - * count is found regardless of casing, read from - * the last spelling (a per-command value spread over process.env), and updated - * under every spelling so the child sees it whichever one Node keeps. A count - * git would reject is left alone so git still reports it. + * Append after inherited entries without changing argv or the user's config. + * Windows env keys ignore case: read the last spelling of the count so command overrides win, + * then update every spelling because Node may keep any one. + * Leave invalid counts untouched so Git reports them. */ export const gitConfigEnv = ( env: NodeJS.ProcessEnv, @@ -117,8 +113,7 @@ export const gitConfigEnv = ( if ( inherited !== undefined && inherited !== "" && - // Git parses the count with strtoul: leading whitespace, an optional sign - // (only -0 is non-negative), digits, and nothing after them. + // Match Git's strtoul count syntax, including whitespace, signs and non-negative -0. /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited ) { return {}; @@ -132,11 +127,8 @@ export const gitConfigEnv = ( }; /** - * Git otherwise opens any folder holding `HEAD`, `objects/` and `refs/` as a - * bare repository and applies its `config`, including commands such as - * `core.fsmonitor`. Files saved in a project or checked out from a clone can - * form one, so git started by T3 (or by a host CLI) only opens a bare - * repository named with `--git-dir` or `GIT_DIR`. Git before 2.38 ignores it. + * A project can contain a fake bare repository whose config runs commands such as `core.fsmonitor`. + * Require `--git-dir` or `GIT_DIR` to open bare repositories; Git before 2.38 ignores this setting. */ export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 74d3801c06861..dfd76b1f2b48b 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -64,7 +64,7 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index e58099b615dbd..96674e940f03b 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -35,7 +35,7 @@ function resolveRequestedKind( return Effect.succeed(kind); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 334c0d4747bce..bdf5dfff6c4ca 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -128,7 +128,6 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b return Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, removeRpcClient: () => Effect.void, - reportHostPowerState: () => Effect.void, snapshot: Effect.succeed({ hostPower: { source: "unknown", @@ -148,14 +147,125 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b shouldRunOpportunisticWork: false, updatedAt: TEST_EPOCH, }), - streamChanges: Stream.empty, - hasDemand: () => Effect.succeed(true), shouldRunScopeWork: (scope) => Effect.sync(() => shouldRunScopeWork(scope)), shouldRunOpportunisticWork: Effect.succeed(true), }); } describe("VcsStatusBroadcaster", () => { + it.effect("publishes a terminal branch switch without reading remote status", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const events = yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.tap((event) => + event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + + state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + assert.equal(refreshed.refName, "main"); + assert.isNull(refreshed.pr); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + assert.deepStrictEqual((yield* Fiber.join(events))[1], { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: null, + }); + assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); + const snapshot = yield* broadcaster + .streamStatus({ cwd: "/repo", includeRemote: false }) + .pipe(Stream.take(1), Stream.runCollect); + assert.deepStrictEqual(snapshot[0], { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: null, + }); + }).pipe(Effect.provide(layerTestFor(state))); + }); + + it.effect( + "preserves cached remote status when a local-only refresh stays on the same branch", + () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 2, behindCount: 3 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + assert.deepStrictEqual(refreshed, initial); + assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo" }), refreshed); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }, + ); + + it.effect("clears the previous branch's remote status after an in-flight PR refresh", () => { + const remoteReadStarted = Deferred.makeUnsafe(); + const releaseRemoteRead = Deferred.makeUnsafe(); + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + beforeRemoteStatus: Effect.void, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + state.beforeRemoteStatus = Deferred.succeed(remoteReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRemoteRead)), + ); + const remoteRefresh = yield* broadcaster + .refreshPullRequestStatus("/repo") + .pipe(Effect.forkScoped); + yield* Deferred.await(remoteReadStarted); + state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + const localRefresh = yield* broadcaster + .refreshStatus("/repo", { localOnly: true }) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseRemoteRead, undefined); + yield* Fiber.join(remoteRefresh); + yield* Fiber.join(localRefresh); + + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(status.refName, "main"); + assert.isNull(status.pr); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state))); + }); + it.effect.skipIf(!symlinksSupported)( "automatically pulls an enabled clean default branch when status detects it is behind", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 71427713e5b88..3cbcb3546e9d9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -207,7 +207,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly refreshStatus: ( + cwd: string, + options?: { readonly localOnly?: boolean }, + ) => Effect.Effect; readonly refreshPullRequestStatus: ( cwd: string, ) => Effect.Effect; @@ -227,7 +230,7 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const automaticFetchInterval = yield* VcsAutomaticFetchInterval; const autoPullPolicy = yield* VcsAutoPullPolicy; @@ -460,8 +463,7 @@ export const make = Effect.gen(function* () { errorTag: error._tag, }); if (!pullAttempted) return null; - // A pull or its follow-up reads can fail after local state has changed. - // Let a failed reconciliation reach the caller instead of publishing old status. + // Pull can change local state before failing; propagate reconciliation failures instead of publishing stale status. yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); yield* updateCachedStatus(cwd, local, remote, { publish: true }); @@ -507,10 +509,25 @@ export const make = Effect.gen(function* () { const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { + )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - // invalidateStatus (not the two partial invalidations) so an explicit - // refresh also bypasses GitManager's slow PR-lookup cache. + if (options?.localOnly) { + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + const previous = yield* getCachedStatus(cwd); + if (previous?.local?.value.refName !== local.refName) { + yield* workflow.invalidateRemoteStatus(cwd); + return yield* updateCachedStatus(cwd, local, null, { publish: true }); + } + yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return mergeGitStatusParts(local, previous?.remote?.value ?? null); + }), + ); + } + // Full invalidation also bypasses GitManager's slow PR cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -544,8 +561,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; - // Resolve the checked-out branch again. A cached PR can belong to - // the previous branch after an agent checks out another branch. + // Re-resolve the branch; cached PRs may belong to a checkout's previous branch. const remote = yield* workflow.remoteStatus( { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index c8feb71c3d845..f1140cdcc3a48 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -10,17 +10,9 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; /** - * Removes the linked worktree at `worktreePath` by running `git worktree - * remove` through `run`. - * - * Git for Windows before 2.54 follows NTFS junctions while it deletes a - * worktree, and deletes what they point to, such as a shared node_modules. - * Later versions skip junctions but leave them, and the folders holding them, - * behind. On Windows, Git therefore never deletes the files: this makes the - * checks `git worktree remove` makes before deleting (builtin/worktree.c), - * deletes the folder with Node's `rm`, which unlinks junctions without - * following them, and then has Git drop the now-missing worktree's metadata. - * Git elsewhere only unlinks links, so other hosts run Git as before. + * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. + * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which + * unlinks junctions without following them. Git then removes the worktree metadata. */ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < A, diff --git a/apps/server/src/workspace/KnownWorkspaces.ts b/apps/server/src/workspace/KnownWorkspaces.ts index 20df30331029b..f442e4cedbb61 100644 --- a/apps/server/src/workspace/KnownWorkspaces.ts +++ b/apps/server/src/workspace/KnownWorkspaces.ts @@ -1,12 +1,4 @@ -/** - * KnownWorkspaces - the folders a client-named cwd may act in: a T3-managed - * worktree folder, a registered project, or a thread's worktree. Clients send - * the thread's worktree, else the project root. A client also sets a thread's - * worktree path, so one counts only under a managed folder or when git lists - * it as a worktree of the thread's project. - * - * @module KnownWorkspaces - */ +// Client-supplied worktrees count only under managed folders or when Git lists them for the project. import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; @@ -35,7 +27,7 @@ export class UnknownWorkspaceError extends Schema.TaggedError { const relative = path.relative(root, candidate); return ( @@ -47,14 +39,14 @@ export const isWithinRoot = (path: Path.Path, candidate: string, root: string) = export class KnownWorkspaces extends Context.Service< KnownWorkspaces, { - /** Whether a symlink-resolved path is inside a known workspace. */ + /** Takes a symlink-resolved path. */ readonly contains: (realPath: string) => Effect.Effect; /** Fails unless `cwd` exists and resolves inside a known workspace. */ readonly assertContains: (cwd: string) => Effect.Effect; } >()("t3/workspace/KnownWorkspaces") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -64,8 +56,7 @@ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; - // A root that does not exist yet keeps its lexical path. One that cannot be - // resolved for another reason, or resolves to a filesystem root, grants nothing. + // Missing roots keep lexical paths; other resolution failures and filesystem roots grant nothing. const resolveRoot = (value: string) => { const resolvedPath = path.resolve(value); return fileSystem.realPath(resolvedPath).pipe( @@ -130,8 +121,7 @@ export const make = Effect.gen(function* () { return real !== null && listed.includes(real); }); for (const { worktreePath, projectId } of threads) { - // A relative path would resolve against the server's own cwd. One under a - // managed folder is still resolved: it can be a symlink to a checkout elsewhere. + // Relative paths use server cwd; managed paths still need resolution to reject symlinks elsewhere. if (!path.isAbsolute(worktreePath)) continue; if (!within(yield* resolveRoot(worktreePath))) continue; const lexical = path.resolve(worktreePath); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 150ac2c69131a..730d02c577f43 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -174,7 +174,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; for (const { directoryPath, ignored, except } of [ { directoryPath: "node_modules", ignored: true, except: [] }, - // A force-tracked file and a folder holding tracked files stay unignored. { directoryPath: "dist", ignored: true, except: ["dist/kept.js", "dist/vendored"] }, // git reads `[id]/page.tsx` as a pathspec, which matches the tracked `i/page.tsx`. { directoryPath: "[id]", ignored: true, except: ["[id]/page.tsx"] }, @@ -183,8 +182,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ? [ // git refuses to classify paths beyond a symlink, so nothing there is labelled. { directoryPath: "node_modules/link", ignored: false, except: [] }, - // A leading `:` is pathspec magic, so git reads `:foo/pkg-0.js` as `foo/pkg-0.js`, - // which `!foo/` keeps unignored. + // Leading : is pathspec magic; Git sees foo/pkg-0.js, unignored by !foo/. { directoryPath: ":foo", ignored: false, except: [] }, ] : []), @@ -203,8 +201,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); it.effect("lists a host folder and its parent, each from its own root", () => - // Folder links outside the workspace browse by passing the host folder as - // `cwd`; walking up passes the parent. Each stays bounded by its own root. Effect.gen(function* () { const parent = yield* makeTempDir(); const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index a0d35202c2114..5f1c5f2660a68 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -145,7 +145,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -334,8 +334,7 @@ export const make = Effect.gen(function* () { }, catch: toError, }); - // Use stdin so large directories cannot exceed the command-line argument limit. - // Ignore classification is optional in non-git workspaces or when git is unavailable. + // stdin avoids argument limits; ignore classification is optional without git/repositories. const checkIgnore = (paths: ReadonlyArray) => vcsProcess .run({ @@ -349,13 +348,8 @@ export const make = Effect.gen(function* () { maxOutputBytes: 16 * 1024 * 1024, }) .pipe(Effect.orElseSucceed(() => undefined)); - // Git consults the index for every path it classifies, which costs hundreds of - // milliseconds for a `node_modules`-sized folder. Asking about the folder itself answers - // for every child: `check-ignore` exits 0 only when the folder is ignored and nothing - // under it is tracked, and an ignored folder hides all of its children. Git reads paths as - // pathspecs, so a folder whose path has magic (`:foo`) or glob (`[id]`) characters, or - // one reached through a symlink, can get a different answer per child and still asks git - // about each. + // Check ignored folders once to avoid child index lookups; exit 0 guarantees no tracked descendants. + // Symlinks and pathspec magic still need per-child checks. if ( entries.length > IGNORE_CHECK_CHUNK_SIZE && target.relativePath !== "" && diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 9eb2ba7580490..0cf5524df4e17 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -26,7 +26,6 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, realpath: vi.fn(actual.realpath) }; }); -// Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({ assertContains: (cwd) => @@ -575,7 +574,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, ".git/config", "[core]\n"); - // A bare repository named `.bare`, and a linked worktree's git folder inside it. yield* writeTextFile(cwd, ".bare/HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(cwd, ".bare/objects/info/packs"); yield* writeTextFile(cwd, ".bare/refs/heads/main"); @@ -649,7 +647,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const parentDir = yield* makeTempDir; - // A bare repository with a worktree checked out inside it. const bare = path.join(parentDir, "repo.git"); yield* writeTextFile(bare, "HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(bare, "objects/info/packs"); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 4e7c81404256b..7107cd99631a8 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -28,8 +28,7 @@ import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; -// Windows also reaches `.git` through trailing dots or spaces, a `:` stream -// suffix, or the 8.3 short name `GIT~1`. Names compare upcased, as NTFS does. +// NTFS reaches .git through trailing dots/spaces, stream suffixes and GIT~1; compare uppercased names. const isGitMetadataName = (segment: string) => { const name = (segment.split(":")[0] ?? "").replace(/[. ]+$/, "").toUpperCase(); return name === ".GIT" || name === "GIT~1"; @@ -133,7 +132,7 @@ export class WorkspaceFileSystem extends Context.Service< } >()("t3/workspace/WorkspaceFileSystem") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -168,7 +167,7 @@ export const make = Effect.gen(function* () { } }, catch: () => undefined, - }).pipe(Effect.catch(() => Effect.succeed(undefined))); + }).pipe(Effect.orElseSucceed(() => undefined)); } return { kind: "file", @@ -196,13 +195,8 @@ export const make = Effect.gen(function* () { ); }; - /** - * Git runs config and hooks from its own folders, so a write never lands in one: - * a `.git` entry, or a repository folder under another name such as `.bare`. - * Folders are checked upward to a linked worktree's top, which holds a `.git` - * file, since worktrees can sit inside a bare repository. `absolutePath` must - * be normalized. - */ + // Reject Git folders, including renamed bare repos whose config/hooks can execute; paths must be normalized. + // Stop at linked worktrees' .git files since worktrees can sit inside bare repos. const isInGitFolder = async (absolutePath: string) => { if (absolutePath.split(path.sep).some(isGitMetadataName)) return true; for (let folder = path.dirname(absolutePath); ; folder = path.dirname(folder)) { @@ -215,8 +209,7 @@ export const make = Effect.gen(function* () { }), ), ); - // What git reads as a repository: HEAD, plus objects and refs here or in - // the folder a `commondir` file names, as in a linked worktree's git folder. + // Git recognizes HEAD with commondir or objects/refs as a repository. if (head && (commondir || (objects && refs))) return true; if (dotGit?.isFile() || path.dirname(folder) === folder) return false; } @@ -357,8 +350,7 @@ export const make = Effect.gen(function* () { return yield* Effect.acquireUseRelease( Effect.tryPromise({ - // Non-blocking so a FIFO cannot hang the open; the stat below rejects - // it. Regular files ignore the flag. Windows lacks it. + // Non-blocking opens prevent FIFO hangs before stat rejects them; Windows lacks the flag. try: () => NodeFSP.open( realTargetPath, @@ -451,12 +443,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * Resolves the file a write targets. The deepest existing ancestor resolves to - * its real path before the root check, so neither the file nor the directories - * created for it can follow a symlink out of the root. A dangling symlink is - * rejected rather than written through. - */ + // Resolve existing ancestors and reject dangling links so creation cannot follow symlinks outside root. const resolveWriteTarget = Effect.fn("WorkspaceFileSystem.resolveWriteTarget")(function* ( input: ProjectWriteFileInput, ) { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 3ae5d524611a4..8f2b64cf26a34 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -27,10 +27,7 @@ import type { } from "@t3tools/contracts"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; -// fff-node stays external to the CLI bundle because it dlopens a native -// library. A static `import` of an external package is a hard error inside a -// Node single-executable (only built-ins resolve there), so load it through -// `require`, which reads from the real filesystem in every runtime. +// fff-node's native library stays external; single-executables require disk packages instead of importing them. const requireForFff = NodeModule.createRequire(import.meta.url); const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); @@ -257,11 +254,7 @@ function mapContentMatchRanges( })); } -/** - * Whole-word filtering happens after the grep rather than by wrapping the pattern in boundary - * regex: consuming boundaries such as `(?:^|\W)` swallow the separator between adjacent matches - * and widen the reported ranges, and `\b` cannot match punctuation-edged queries at all. - */ +// Post-filter whole words: consuming boundary regexes swallow adjacent separators and widen ranges; \b misses punctuation edges. function isWholeWordRange( line: string, range: { readonly start: number; readonly end: number }, @@ -303,9 +296,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( FileFinder.create({ basePath: cwd, disableMmapCache: true, - // Content indexing costs scan CPU and memory, so only the on-demand - // content-search index pays for it; path-only consumers (file tree, - // composer path search, file picker) keep the lightweight index. + // Content indexing costs CPU/memory; path-only consumers use the lightweight index. disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, @@ -490,8 +481,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( )(function* (input) { const { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; - // Grep cursors advance by file, so whole-word post-filtering needs enough - // raw candidates from the current file before moving to the next one. + // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. const rawPageSize = input.wholeWord ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) : input.limit; @@ -555,9 +545,7 @@ function parseWorkspaceSearchIndexKey(key: string): { }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const layer = (key: string) => { const { cwd, variant } = parseWorkspaceSearchIndexKey(key); return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index aef9b8c031c43..e8464eb077f9d 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -8,11 +8,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerConfig from "./config.ts"; import * as ServerSettings from "./serverSettings.ts"; -/** - * Null when the setting is not an absolute path on this machine, such as `D:\worktrees` - * configured for a Windows server and synced to a Linux one, or when it is a filesystem root, - * which would make every path on that drive look managed. - */ +/** Null for filesystem roots or settings not absolute on this host, including synced foreign-platform paths. */ export function resolveWorktreesDirectory( setting: string, defaultDir: string, @@ -48,19 +44,14 @@ export function managedWorktreesDirectories( return [...directories]; } -/** - * A thread's path and branch can come from a client, so a missing worktree is - * only recreated where the server would have put it: - * `//`, compared by real path. - */ +// Client paths/branches are untrusted; recreate only real paths matching managed-folder/repo/branch. export const makeIsManagedWorktreePath = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; - // Where git would create `target`: the real path of its nearest existing - // ancestor plus the missing rest, since git follows symlinks on the way. + // Match Git's symlink-following location: nearest real ancestor plus missing path components. const realLocation = (target: string): Effect.Effect => fileSystem.realPath(target).pipe( Effect.catch(() => { @@ -74,8 +65,7 @@ export const makeIsManagedWorktreePath = Effect.gen(function* () { return Effect.fn("worktreesDirectory.isManagedWorktreePath")(function* (worktreePath: string) { // git resolves a relative path against the project, not the server cwd. if (!path.isAbsolute(worktreePath)) return false; - // Server-made paths are normalized. A `..` would be applied after git - // follows a symlink but before realPath does, so the two disagree. + // Require normalized paths; Git and realPath disagree on .. after symlink traversal. if (path.resolve(worktreePath) !== worktreePath) return false; const settings = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => ({ diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index bbdfef2f4025e..a1b519c2c5f2a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -6,19 +6,26 @@ import { EnvironmentId, ProjectId, ThreadId, + ProviderDriverKind, + ProviderInstanceId, WS_METHODS, + WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, + type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, + type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; @@ -36,6 +43,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; @@ -47,6 +55,8 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; +import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -69,6 +79,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -76,6 +87,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as DatabaseStorage from "./persistence/DatabaseStorage.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -90,6 +102,7 @@ import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as TriageService from "./triage/TriageService.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfigModule from "./config.ts"; @@ -297,11 +310,173 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("coalesces streaming replay before synchronization and the live tail", () => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:streaming-replay"); + const now = "2026-10-27T00:00:00.000Z"; + const insert = (version: number, text: string, streaming: boolean) => sql<{ + sequence: number; + }>` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, + occurred_at, actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ( + ${`streaming-replay:${version}`}, 'thread', ${threadId}, ${version}, 'message.updated', + ${now}, 'server', ${JSON.stringify({ + createdBy: "agent", + creationSource: "web", + id: "message:streaming-replay", + threadId, + runId: null, + nodeId: null, + role: "assistant", + text, + attachments: [], + streaming, + createdAt: now, + updatedAt: now, + })}, '{}', 2 + ) RETURNING sequence + `; + yield* insert(0, "H", true); + yield* insert(1, "He", true); + const [latest] = yield* insert(2, "Hello", true); + const [completed] = yield* insert(3, "Hello!", false); + const items = yield* subscribeOrchestrationV2Thread({ + threadId, + afterSequence: 0, + requestCompletionMarker: true, + }).pipe( + Effect.flatMap(Stream.runCollect), + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + streamStoredEventsFrom: () => + Stream.unwrap( + insert(4, "Next", false).pipe( + Effect.map(() => + store.readAgentEvents({ threadId, afterSequence: completed!.sequence }), + ), + ), + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + ); + assert.deepEqual( + items.map((item) => + item.kind === "event" + ? [item.sequence, item.event.type === "message.updated" ? item.event.payload.text : null] + : [item.kind], + ), + [ + [latest!.sequence, "Hello"], + [completed!.sequence, "Hello!"], + ["synchronized"], + [completed!.sequence + 1, "Next"], + ], + ); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + +it.effect("shell snapshots omit idle children and older settled threads only on request", () => + Effect.gen(function* () { + const shell = ( + id: string, + parent: string | null, + activityRunStatus: "running" | null, + settledDaysAgo: number | null = null, + ) => + ({ + id: ThreadId.make(id), + projectId: "project", + archivedAt: null, + status: activityRunStatus ?? "completed", + activityRunStatus, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + settledOverride: settledDaysAgo === null ? null : "settled", + settledAt: + settledDaysAgo === null ? null : DateTime.makeUnsafe(-settledDaysAgo * 86_400_000), + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make(parent ?? id), + }, + }) as unknown as OrchestrationV2ThreadShell; + const threads = [ + shell("parent", null, null), + shell("idle", "parent", null), + shell("live", "parent", "running"), + ...Array.from({ length: 25 }, (_, index) => shell(`recent-${index}`, null, null, 1)), + shell("old", null, null, 30), + ]; + const firstSnapshot = (input: { + readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; + }) => + subscribeOrchestrationV2Shell(input).pipe( + Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), + Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), + Effect.provideService(ThreadManagement.ThreadManagementService, { + readShellSnapshot: () => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads, + archivedThreads: [], + }), + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedNone, + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + const recent = threads.slice(3, 28).map((thread) => thread.id); + const full = yield* firstSnapshot({}); + assert.deepEqual( + full?.threads.map((thread) => thread.id), + threads.map((thread) => thread.id), + ); + assert.isUndefined(full?.olderSettled); + const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); + assert.deepEqual( + withoutChildren?.threads.map((thread) => thread.id), + ["parent", "live", ...recent, "old"], + ); + const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); + assert.deepEqual( + withoutOld?.threads.map((thread) => thread.id), + ["parent", "live", ...recent], + ); + assert.deepEqual(withoutOld?.olderSettled?.counts, [ + { projectId: ProjectId.make("project"), count: 1 }, + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + // Build the production RPC layer; unrelated capabilities fail if this test calls them. const layerUnusedConfigRpcServices = Layer.mergeAll( + Layer.mock(DocumentText.DocumentText)({}), Layer.mock(ServerSecretStore.ServerSecretStore)({}), Layer.mock(WorkspacePaths.WorkspacePaths)({}), Layer.mock(TraceDiagnostics.TraceDiagnostics)({}), + Layer.mock(TriageService.TriageService)({}), Layer.mock(ProjectFaviconResolver.ProjectFaviconResolver)({}), Layer.mock(ProjectEnrichment.ProjectEnrichmentService)({}), Layer.mock(AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator)({}), @@ -324,7 +499,9 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({}), Layer.mock(OtlpEndpointCheck.OtlpEndpointCheck)({}), Layer.mock(PairingGrantStore.PairingGrantStore)({}), - Layer.mock(PortScanner.PortDiscovery)({}), + Layer.mock(PeerLinks.PeerLinks)({}), + Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => () => undefined }), Layer.mock(PreviewManager.PreviewManager)({}), Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}), Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({}), @@ -346,12 +523,14 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), Layer.mock(SessionStore.SessionStore)({ cookieName: "config-test", legacyCookieName: undefined }), Layer.mock(SourceControlDiscovery.SourceControlDiscovery)({}), Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), + Layer.mock(SourceControlSignIn.SourceControlSignIn)({}), Layer.mock(StorageCleanup.StorageCleanup)({}), Layer.mock(DatabaseStorage.DatabaseStorage)({}), Layer.mock(TerminalManager.TerminalManager)({}), @@ -409,6 +588,7 @@ it.effect( Effect.andThen(Deferred.await(finishDiscovery)), Effect.as([]), ), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. resolveFileManagerRevealKind: () => Effect.succeed(undefined), }), Layer.mock(ProviderRegistry.ProviderRegistry)({ @@ -476,8 +656,6 @@ it.effect( scopes: [], }, {}, - {}, - yield* PreviewAutomationBroker.PreviewAutomationBroker, yield* ServerBrowser.ServerBrowser, yield* McpGatewayBroker.McpGatewayBroker, yield* ClientFocusBroker.ClientFocusBroker, @@ -543,6 +721,11 @@ it.effect( Layer.fresh(layerConfigSources), Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), Layer.mock(ServerBrowser.ServerBrowser)({}), + Layer.mock(McpGatewayBroker.McpGatewayBroker)({ + grants: () => ({}), + available: () => false, + }), + Layer.mock(ClientFocusBroker.ClientFocusBroker)({}), McpGatewayBroker.layer.pipe(Layer.provide(NodeCrypto.layer)), Layer.mock(ClientFocusBroker.ClientFocusBroker)({}), ), @@ -551,6 +734,168 @@ it.effect( ), ); +it.effect("on-demand clients get provider config without workspace snapshots", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const snapshotFor = (cwd: string, checkedAt: string) => ({ + cwd, + checkedAt, + slashCommands: [{ name: `${cwd.slice(1)}-command` }], + skills: [], + }); + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-25T00:00:00.000Z", + models: [], + slashCommands: [{ name: "machine-command" }], + skills: [], + workspaceSnapshots: [ + snapshotFor("/a", "2026-10-25T00:00:00.000Z"), + snapshotFor("/b", "2026-10-25T00:00:00.000Z"), + ], + }; + const providerChanges = yield* Queue.unbounded>(); + const pulled = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + streamChanges: Stream.fromQueue(providerChanges).pipe( + Stream.tap(() => Queue.offer(pulled, undefined)), + ), + refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.make([]), + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { + discard: true, + }) + : response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + yield* ServerBrowser.ServerBrowser, + yield* McpGatewayBroker.McpGatewayBroker, + yield* ClientFocusBroker.ClientFocusBroker, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const configRequest = RpcMessage.RequestId("config"); + yield* server.write(0, { + _tag: "Request", + id: configRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); + yield* server.write(0, { _tag: "Ack", requestId: configRequest }); + + // A rescan that only touches a snapshot sends nothing; the next real change does. + yield* Queue.offer(providerChanges, [ + { ...provider, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] }, + ]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + yield* Queue.offer(providerChanges, [{ ...provider, status: "warning" }]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); + assert.equal(update.payload.providers[0]?.status, "warning"); + assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), + [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], + ); + yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + Layer.mock(McpGatewayBroker.McpGatewayBroker)({ + grants: () => ({}), + available: () => false, + }), + Layer.mock(ClientFocusBroker.ClientFocusBroker)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same @@ -601,8 +946,7 @@ it.effect("thread resume across compacted history keeps message order and final ) `; } - // Only the event between a's first and newest is deleted. - assert.equal((yield* maintenance.compactEventStore).deletedEventCount, 1); + assert.equal((yield* maintenance.compactEventStore()).deletedEventCount, 1); const items = yield* subscribeOrchestrationV2Thread({ threadId, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5967283a34898..94461200be0c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,6 +4,8 @@ import * as McpGatewayBroker from "./mcp/McpGatewayBroker.ts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; +import * as PeerLinks from "./peer/PeerLinks.ts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -21,7 +23,6 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import { rpcInitialItems } from "./rpcInitialItems.ts"; import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; -import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; import { AcpRegistryOperationError, CommandId, @@ -40,7 +41,6 @@ import { type DiscoveredLocalServerList, type EditorId, type FileManagerRevealKind, - type OrchestrationClientOrigin, type OrchestrationV2Command, type GitActionProgressEvent, type GitManagerServiceError, @@ -64,6 +64,7 @@ import { OrchestrationV2ThreadLaunchError, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadStreamItem, type ApplicationStoredEvent, type ProjectEntriesFailure, type ProjectFileFailure, @@ -82,6 +83,7 @@ import { type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, + type ServerProvider, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -120,23 +122,25 @@ import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as TodoService from "./todo/TodoService.ts"; import * as ThreadTaskService from "./threadTask/ThreadTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import { - archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, bufferShellLiveStream, coalesceShellApplicationEvents, - coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, + loadOlderSettledThreadsPage, loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, + shellSnapshotForSubscriber, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -144,8 +148,10 @@ import { type ShellApplicationEvent, } from "./orchestration-v2/ShellStream.ts"; import { ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION } from "./orchestration-v2/ProjectionStore.ts"; -import { bufferLiveStream } from "./orchestration-v2/LiveStreamBudget.ts"; -import { coalesceThreadLiveStream } from "./orchestration-v2/ThreadLiveEventCoalescer.ts"; +import { + coalesceLiveUpdatedEvents, + coalesceThreadLiveStream, +} from "./orchestration-v2/ThreadLiveEventCoalescer.ts"; import { buildBoundedThreadStreamSnapshot, decideThreadResume, @@ -159,9 +165,11 @@ import { THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, } from "./orchestration-v2/threadHistoryPaging.ts"; import { + makeLiveBodyDeduper, projectDomainEventForWire, projectThreadProjectionForWire, } from "./orchestration-v2/WireProjection.ts"; +import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; @@ -185,7 +193,6 @@ import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { terminalOutputStream, withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as WsClientLiveness from "./wsClientLiveness.ts"; -import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; import * as DeviceService from "./device/DeviceService.ts"; import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; @@ -239,6 +246,7 @@ import { commandFeatureUsage, featureUsage } from "./telemetry/FeatureUsage.ts"; import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as TriageService from "./triage/TriageService.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as PullRequestReviewGuideService from "./pullRequest/PullRequestReviewGuideService.ts"; import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; @@ -248,6 +256,7 @@ import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReact import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -322,8 +331,7 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so - // a timeout here would cancel a probe that outlasts it every time. + // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -356,8 +364,7 @@ export const withLateEditorConfig = ( return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": return [{ ...current, settings: event.payload.settings }, [event]]; - // Themes and usage-limit sources never ride in a snapshot; clients - // carry their projected values across one. + // Themes and usage-limit sources survive snapshots because snapshots never include them. default: return [current, [event]]; } @@ -366,6 +373,10 @@ export const withLateEditorConfig = ( ); }; +function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); +} + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -577,9 +588,7 @@ const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); // Replaying each intervening event costs a shell refetch; past this gap a // single O(active-threads) snapshot is cheaper and bounded. const SHELL_RESUME_MAX_GAP = 1_000; -// Row count alone does not bound replay memory: a few events with large tool -// payloads can decode to gigabytes. Before replaying, sum the serialized -// payload bytes of the range in SQL and reset with a snapshot past this budget. +// Row counts do not bound large tool payloads; sum stored JSON bytes in SQL and snapshot above budget. const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; function toAuthAccessStreamEvent( @@ -641,29 +650,12 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; } -function readClientConnectionOrigin( - request: HttpServerRequest.HttpServerRequest, -): OrchestrationClientOrigin { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return {}; - } - const surface = url.value.searchParams.get("clientSurface"); - const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; - return { - ...(isClientSurface(surface) ? { surface } : {}), - ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH - ? { appVersion } - : {}), - }; -} - -// Client telemetry stays in this socket's RPC layer. It must not become a -// server-global "current client" because several client types can connect at once. +// Keep telemetry per socket; several client types can connect simultaneously. function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -792,9 +784,16 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptDedupedBodies?: boolean; + readonly acceptCompactIds?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const dedupeBodies = input.acceptDedupedBodies === true; + // Replay, snapshot, and live tail share one deduper: it must see every delivered record. + const withLiveDedupe = (stream: Stream.Stream) => + dedupeBodies ? Stream.suspend(() => Stream.map(stream, makeLiveBodyDeduper())) : stream; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; yield* Effect.annotateCurrentSpan({ @@ -841,9 +840,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs afterSequence, throughSequence, limit: THREAD_RESUME_MAX_REPLAY_EVENTS + 1, - // A row a newer build wrote (e.g. after a downgrade) must not fail - // every resubscribe. The live tail resumes from `highWater`, so a - // dropped row never hides later events. + // Downgrades can leave unknown rows; skip them and resume live from highWater so later events survive. skipUnknownEventTypes: true, }) .pipe( @@ -875,6 +872,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs useBoundedSnapshot ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork: input.acceptDeferredWork === true, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) @@ -893,21 +891,25 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, + dedupeBodies, + compactIds: input.acceptCompactIds === true, }) - : { - kind: "snapshot" as const, - snapshotSequence, - projection: projectThreadProjectionForWire(snapshot.projection), - }; - return Stream.concat( - Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), - eventStreamFrom(snapshotSequence), + : (() => { + const projection = projectThreadProjectionForWire(snapshot.projection); + return { + kind: "snapshot" as const, + snapshotSequence, + projection: dedupeBodies ? dedupeSnapshotBodies(projection) : projection, + }; + })(); + return rpcInitialItems([snapshotItem]).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(snapshotSequence)), + withLiveDedupe, ); }); - // The event sink subscribes to live events before reading the persisted - // tail, so no event published during the replay window is lost; overlapping - // events are deduped by sequence on the client. + // Subscribe before reading persisted events to retain concurrent publishes; clients dedupe overlap by sequence. if (input.afterSequence !== undefined) { const highWater = yield* applicationEvents.latestAgentSequence(input.threadId).pipe( Effect.mapError( @@ -939,8 +941,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // Bound stored JSON before decoding, then check projected event - // size separately. Neither byte count is a bound on process memory. + // Bound stored and projected payload bytes separately; neither bounds process memory. if ( stats.eventCount > THREAD_RESUME_MAX_REPLAY_EVENTS || !isThreadReplayRawPayloadSafe(stats.rawPayloadBytes) @@ -958,11 +959,12 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // A retained creation can belong to a thread already deleted. - // Only replace its bounded replay when a snapshot can exist. + // Retained creation events may describe deleted threads; replace replay only if a snapshot exists. if (shell !== null) return yield* snapshotThenLive(); } - const replay = yield* loadReplayThrough(input.afterSequence, highWater); + const replay = coalesceLiveUpdatedEvents( + yield* loadReplayThrough(input.afterSequence, highWater), + ); const plan = decideThreadResume({ afterSequence: input.afterSequence, highWater, @@ -972,9 +974,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (plan.mode === "snapshot") { return yield* snapshotThenLive(); } - return Stream.concat( - Stream.concat(rpcInitialItems(replay), completionMarker), - eventStreamFrom(highWater), + return rpcInitialItems(replay).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(highWater)), + withLiveDedupe, ); } @@ -986,6 +989,8 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1022,14 +1027,18 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, }), ); - subagentThreadIds = new Set( - base.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + const forSubscriber = shellSnapshotForSubscriber(base, { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + now: yield* DateTime.now, + }); + subagentThreadIds = forSubscriber.subagentThreadIds; const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...forSubscriber.snapshot, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1125,11 +1134,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc ), ); - // Always attach the enrichment subscription before the first load so - // completions that race HTTP snapshot fetch still push a refresh. We still - // emit one compact metadata refresh up front: getAvailable may have been - // cold on the HTTP path (null identity), and enrichment PubSub events - // published before this subscribe attached are dropped. + // Subscribe before loading for racing enrichments; initial refresh covers pre-subscription completions and cold HTTP snapshots. const completionMarker = input.requestCompletionMarker === true ? Stream.make({ kind: "synchronized" as const }) @@ -1154,9 +1159,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots, }), ); - // Initial unmarked (+ optional same-load marked) always drains first. - // Enrichment merges only with the post-prefix tail so a ready marked - // refresh cannot interleave before the authoritative initial frame. + // Drain initial frames before merging enrichment with live events so marked refreshes cannot precede the initial frame. const completionThenLive = (afterSequence: number) => Stream.concat(completionMarker, liveFrom(afterSequence)); @@ -1225,9 +1228,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc export const layerWsRpc = ( currentSession: EnvironmentAuth.AuthenticatedSession, - clientOrigin: OrchestrationClientOrigin, clientAnalyticsProps: Readonly>, - previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], serverBrowser: ServerBrowser.ServerBrowser["Service"], mcpGatewayBroker: McpGatewayBroker.McpGatewayBroker["Service"], clientFocusBroker: ClientFocusBroker.ClientFocusBroker["Service"], @@ -1245,6 +1246,7 @@ export const layerWsRpc = ( FileSystem.FileSystem, ServerConfig.ServerConfig, ServerSettings.ServerSettingsService, + DocumentText.DocumentText, )( yield* Effect.context< | ThreadManagementService.ThreadManagementService @@ -1252,6 +1254,7 @@ export const layerWsRpc = ( | FileSystem.FileSystem | ServerConfig.ServerConfig | ServerSettings.ServerSettingsService + | DocumentText.DocumentText >(), ); const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; @@ -1266,18 +1269,21 @@ export const layerWsRpc = ( const analytics = yield* AnalyticsService.AnalyticsService; const originProps = clientAnalyticsProps; const recordClientCommandAnalytics = (command: OrchestrationV2Command) => { - if (command.type === "message.dispatch") { - return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore); - } const usage = commandFeatureUsage(command); - return usage === undefined - ? Effect.void - : analytics - .record("feature.used", { - ...usage, - ...(originProps.surface === undefined ? {} : { surface: originProps.surface }), - }) - .pipe(Effect.ignore); + return Effect.all( + [ + command.type === "message.dispatch" + ? analytics.record("client.turn.requested", originProps) + : Effect.void, + usage === undefined + ? Effect.void + : analytics.record("feature.used", { + ...usage, + ...(originProps.surface === undefined ? {} : { surface: originProps.surface }), + }), + ], + { discard: true }, + ).pipe(Effect.ignore); }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; @@ -1298,6 +1304,7 @@ export const layerWsRpc = ( cause, }); const secretRequests = yield* SecretRequests.SecretRequests; + const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -1307,6 +1314,10 @@ export const layerWsRpc = ( const orchestrationEngine = yield* Orchestrator.OrchestratorV2; const commandReceipts = yield* OrchestrationCommandReceiptRepository; const crypto = yield* Crypto.Crypto; + // RPC client ids are only unique within one socket; terminals share viewers across sockets. + const terminalViewerPrefix = yield* crypto.randomUUIDv4().pipe(Effect.orDie); + const terminalViewerId = (metadata: { readonly client: { readonly id: number } }) => + `${terminalViewerPrefix}:${metadata.client.id}`; const serverCommandId = (tag: string) => crypto.randomUUIDv4().pipe( Effect.orDie, @@ -1360,6 +1371,8 @@ export const layerWsRpc = ( const storageCleanup = yield* StorageCleanup.StorageCleanup; const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; + const peerLinks = yield* PeerLinks.PeerLinks; + const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -1385,16 +1398,17 @@ export const layerWsRpc = ( const otlpEndpointCheck = yield* OtlpEndpointCheck.OtlpEndpointCheck; const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const sourceControlSignIn = yield* SourceControlSignIn.SourceControlSignIn; const withPullRequestViewer = pullRequests.withRoutingCredential; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const triage = yield* TriageService.TriageService; const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; - // A webhook URL starts agent runs, so only sessions that may operate - // see it; read-only sessions still see the task itself. + // Webhook URLs start agents; expose them only to operate-scoped sessions while keeping tasks readable. const withVisibleWebhookUrls = (result: ScheduledTaskListResult): ScheduledTaskListResult => currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result @@ -1422,16 +1436,14 @@ export const layerWsRpc = ( }), ), ); - return yield* Option.match(project, { - onNone: () => - Effect.fail( - new AcpRegistryOperationError({ - reason: "project_not_found", - message: `Project ${projectId} was not found.`, - }), - ), - onSome: Effect.succeed, - }); + return yield* Effect.fromOption( + project, + () => + new AcpRegistryOperationError({ + reason: "project_not_found", + message: `Project ${projectId} was not found.`, + }), + ); }); const acpSessionManager = Effect.fn("ws.acpRegistry.sessionManager")(function* ( @@ -1745,12 +1757,16 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; + readonly workspaceSnapshotsOnDemand: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const commandProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = options.workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); @@ -1806,6 +1822,8 @@ export const layerWsRpc = ( }, settings, shellResumeCompletionMarker: true, + shellThreadFamilies: true, + shellOlderSettled: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, threadFind: true, @@ -1852,41 +1870,25 @@ export const layerWsRpc = ( ), ); - const subscribeOrchestrationV2ArchivedShell = Effect.fn( - "ws.orchestrationV2.subscribeArchivedShell", - )(function* () { - const snapshot = yield* getOrchestrationV2ArchivedShellSnapshot; - const live = threadManagement - .streamStoredEventsFrom({ afterSequence: snapshot.snapshotSequence }) - .pipe( - Stream.groupedWithin(512, Duration.millis(50)), - Stream.mapEffect((events) => - Effect.forEach( - coalesceStoredThreadEvents(Array.from(events)), - (stored) => - threadManagement - .getThreadShell(stored.event.threadId) - .pipe( - Effect.map((shell) => - archivedShellStreamItemFromThreadShell({ stored, shell }), - ), - ), - { concurrency: 8 }, - ), - ), - Stream.flatMap(Stream.fromIterable), - Stream.filterMap((item) => (item === null ? Result.failVoid : Result.succeed(item))), - (stream) => bufferLiveStream(stream), - Stream.mapError( - (cause) => - new OrchestrationV2GetShellSnapshotError({ - message: "Failed while streaming archived threads", - cause, - }), + // Rows the shell can omit, read with the same snapshot sequence the shell uses. + const readOmittableShellRows = Effect.fn("ws.orchestrationV2.readOmittableShellRows")( + function* ( + options: { readonly familyOf: ThreadId } | { readonly worktreePath: string }, + message: string, + ) { + const { threads, snapshotSequence } = yield* loadShellSnapshotParts({ + sql, + readThreads: threadManagement.readShellSnapshot({ location: "active", ...options }), + listProjects: Effect.succeed([]), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => new OrchestrationV2GetShellSnapshotError({ message, cause }), ), ); - return Stream.concat(rpcInitialItems([{ kind: "snapshot" as const, snapshot }]), live); - }); + return { snapshotSequence, threads: threads.threads }; + }, + ); const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) { const result = yield* projectMutationOperation(projectService, mutation); @@ -2015,7 +2017,15 @@ export const layerWsRpc = ( .searchThreadStream(input) .pipe(Stream.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => - threadSearch.search(input).pipe( + ThreadSearch.searchThreadsWithOlderSettledRows({ + request: input, + search: threadSearch.search, + searchOlderSettled: threadSearch.searchOlderSettled, + sql, + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( Effect.mapError( (cause) => new OrchestrationSearchThreadsError({ @@ -2026,11 +2036,33 @@ export const layerWsRpc = ( ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => getOrchestrationV2ArchivedShellSnapshot, + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => + readOmittableShellRows({ familyOf: input.threadId }, "Failed to load the thread family"), + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: (input) => + loadOlderSettledThreadsPage({ + sql, + listThreads: threadManagement.listOlderSettledThreads(input), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetShellSnapshotError({ + message: "Failed to load older settled threads", + cause, + }), + ), + ), + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: (input) => + readOmittableShellRows( + { worktreePath: input.worktreePath }, + "Failed to load the worktree's threads", + ).pipe(Effect.map(({ threads }) => ({ threadIds: threads.map((thread) => thread.id) }))), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen( - // Pre-pagination clients still call this compatibility endpoint. - // Keep stale clients from materializing an unbounded transcript. + // Bound compatibility snapshots so pre-pagination clients cannot materialize unlimited transcripts. threadManagement .getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, @@ -2160,8 +2192,6 @@ export const layerWsRpc = ( ), ), ), - [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: (_input) => - Stream.unwrap(subscribeOrchestrationV2ArchivedShell()), [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: (input) => Stream.unwrap(subscribeOrchestrationV2Shell(input)), [ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input) => @@ -2176,8 +2206,6 @@ export const layerWsRpc = ( Effect.as(commandOutputHub.subscribe(input)), ), ), - [WS_METHODS.scheduledTasksList]: (_input) => - scheduledTasks.list().pipe(Effect.map(withVisibleWebhookUrls)), [WS_METHODS.scheduledTasksSubscribe]: (_input) => scheduledTasks.subscribeList().pipe(Stream.map(withVisibleWebhookUrls)), [WS_METHODS.scheduledTasksUpsert]: (input) => scheduledTasks.upsert(input), @@ -2232,6 +2260,10 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), + [WS_METHODS.guestShareCreate]: (input) => + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(guestShares.create(input)), + ), [WS_METHODS.secretsAnswerRequest]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(secretRequests.answer(input)), @@ -2245,7 +2277,8 @@ export const layerWsRpc = ( Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), - [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), + [WS_METHODS.serverGetConfig]: (_input) => + loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -2374,8 +2407,7 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } - // Awaited, not forked: the RPC scope closes on return and would - // interrupt a fork before the hub answered. + // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } @@ -2415,7 +2447,29 @@ export const layerWsRpc = ( providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } - return { providers }; + if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const commandProviders = + input.usageLimitsCommand === true + ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) + : providers; + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) { + return { providers: withoutWorkspaceSnapshots(commandProviders) }; + } + return { + providers: commandProviders + .filter((provider) => provider.instanceId === instanceId) + .map((provider) => ({ + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd === cwd, + ), + })), + }; }), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), @@ -2434,29 +2488,23 @@ export const layerWsRpc = ( ) ?? projection.providerThreads.at(-1); const providerSessionId = providerThread?.providerSessionId ?? null; if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "No provider session has run in this thread yet.", + }); } const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }); } if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }); } return yield* runtime.uploadFeedback({ providerThread, @@ -2520,7 +2568,6 @@ export const layerWsRpc = ( [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input), [WS_METHODS.chatGptHandoffSubscribe]: (input) => subscribeChatGptHandoff(input, currentSessionId), - [WS_METHODS.codexAuthCallbackSubscribe]: (input) => subscribeCodexAuthCallback(input), [WS_METHODS.providerAuthCancel]: (input) => providerAuth.cancel(input, currentSessionId), [WS_METHODS.providerAuthLogout]: (input) => providerAuth.logout(input), [WS_METHODS.providerAuthSubscribe]: (input) => @@ -2549,8 +2596,15 @@ export const layerWsRpc = ( [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetDatabaseStorage]: () => databaseStorage.status, - [WS_METHODS.serverGetSettings]: (_input) => - serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + [WS_METHODS.peerLinksList]: (_input) => + peerLinks.list.pipe(Effect.map((links) => ({ links }))), + [WS_METHODS.peerLinksLink]: (input) => peerLinks.link(input), + [WS_METHODS.peerLinksUnlink]: ({ environmentId }) => + peerLinks.unlink(environmentId).pipe(Effect.map((removed) => ({ removed }))), + [WS_METHODS.peerLinksAnswerRequest]: (input) => + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(peerLinkRequests.answer(input)), + ), [WS_METHODS.serverUpdateSettings]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ({ patch, providerInstanceMutation, replicateProfiles }) { @@ -2581,6 +2635,7 @@ export const layerWsRpc = ( [WS_METHODS.serverRefreshUsageRates]: (_input) => usage.refreshRates, [WS_METHODS.serverRetryResourceTelemetry]: (_input) => resourceTelemetry.retry, [WS_METHODS.serverCheckOtlpEndpoint]: (input) => otlpEndpointCheck.check(input), + [WS_METHODS.serverPrepareTriage]: (input) => triage.prepare(input), [WS_METHODS.serverSignalProcess]: (input) => processDiagnostics.signal(input), [WS_METHODS.serverReportClientActivity]: (input, metadata) => Ref.update(rpcClientIds, (clientIds) => { @@ -2603,12 +2658,10 @@ export const layerWsRpc = ( input, ), ), + [WS_METHODS.serverGetBackgroundPolicy]: () => backgroundPolicy.snapshot, [WS_METHODS.clientsList]: (_input) => Effect.map(clientFocusBroker.list, (clients) => ({ clients })), [WS_METHODS.clientsFocus]: (input) => clientFocusBroker.focus(input), - [WS_METHODS.serverReportHostPowerState]: (input) => - backgroundPolicy.reportHostPowerState(input), - [WS_METHODS.serverGetBackgroundPolicy]: (_input) => backgroundPolicy.snapshot, [WS_METHODS.cloudGetRelayClientStatus]: (_input) => relayClient.resolve, [WS_METHODS.cloudInstallRelayClient]: (_input) => Stream.callback((queue) => @@ -2728,6 +2781,10 @@ export const layerWsRpc = ( sourceControlRepositories.lookupRepository(input), [WS_METHODS.sourceControlCloneRepository]: (input) => sourceControlRepositories.cloneRepository(input), + [WS_METHODS.sourceControlSignInStart]: (input) => sourceControlSignIn.start(input), + [WS_METHODS.sourceControlSignInWait]: (input) => sourceControlSignIn.wait(input), + [WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input), + [WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input), [WS_METHODS.projectCloneStart]: (input) => projectCloneTracker.start(input, { createProject: (project) => @@ -2931,8 +2988,7 @@ export const layerWsRpc = ( resource: input.resource, }); } - // A cloned project exists before its files do. Clients ask again - // when the clone lands (see createProjectFaviconUrlAtomFamily). + // Clone projects precede their files; clients retry asset URLs after clone completion. const clone = yield* projectCloneTracker.get(project.value.projectId); return yield* issueAssetUrl({ resource: input.resource, @@ -2982,7 +3038,8 @@ export const layerWsRpc = ( worktreeSetupTracker .cancel(input.threadId) .pipe(Effect.map((cancelled) => ({ cancelled }))), - [WS_METHODS.vcsRefreshStatus]: (input) => vcsStatusBroadcaster.refreshStatus(input.cwd), + [WS_METHODS.vcsRefreshStatus]: (input) => + vcsStatusBroadcaster.refreshStatus(input.cwd, { localOnly: input.localOnly ?? false }), [WS_METHODS.vcsPull]: (input) => gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ @@ -3053,17 +3110,12 @@ export const layerWsRpc = ( [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), - [WS_METHODS.terminalAttach]: (input) => - terminalOutputStream( - (listener, registerCleanup) => - terminalManager.attachStream(input, listener, registerCleanup), - (listener, registerCleanup) => - terminalManager.resyncStream(input, listener, registerCleanup), - ), + [WS_METHODS.terminalAttach]: (input, metadata) => + terminalManager.attachOutputStream(input, terminalViewerId(metadata)), [WS_METHODS.terminalWrite]: (input) => terminalManager.write(input), - [WS_METHODS.terminalResize]: (input) => terminalManager.resize(input), + [WS_METHODS.terminalResize]: (input, metadata) => + terminalManager.resize(input, terminalViewerId(metadata)), [WS_METHODS.terminalClear]: (input) => terminalManager.clear(input), - [WS_METHODS.terminalRestart]: (input) => terminalManager.restart(input), [WS_METHODS.terminalClose]: (input) => terminalManager.close(input), [WS_METHODS.terminalObserve]: (input) => terminalOutputStream( @@ -3072,8 +3124,6 @@ export const layerWsRpc = ( (listener, registerCleanup) => terminalManager.resyncStream(input, listener, registerCleanup), ), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - terminalOutputStream(terminalManager.subscribe), [WS_METHODS.subscribeTerminalMetadata]: (_input) => Stream.callback((queue) => Effect.acquireRelease( @@ -3082,10 +3132,8 @@ export const layerWsRpc = ( ), ), [WS_METHODS.previewOpen]: (input) => previewManager.open(input), - [WS_METHODS.previewNavigate]: (input) => previewManager.navigate(input), [WS_METHODS.previewResize]: (input) => previewManager.resize(input), [WS_METHODS.previewAdjust]: (input) => previewManager.adjust(input), - [WS_METHODS.previewRefresh]: (input) => previewManager.refresh(input), [WS_METHODS.previewClose]: (input) => previewManager.close(input), [WS_METHODS.previewList]: (input) => previewManager.list(input), [WS_METHODS.previewClearProfile]: (input) => serverBrowser.clearProfile(input.profileId), @@ -3119,6 +3167,7 @@ export const layerWsRpc = ( [WS_METHODS.deviceDetail]: (input) => deviceService.detail(input), [WS_METHODS.deviceAction]: (input) => deviceService.action(input), [WS_METHODS.subscribeDeviceState]: (_input) => DeviceService.stateStream(deviceService), + [WS_METHODS.previewStopLocalServer]: (input) => portDiscovery.stopServer(input), [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => Stream.callback((queue) => Effect.gen(function* () { @@ -3149,10 +3198,14 @@ export const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; + const workspaceSnapshotsOnDemand = input.workspaceSnapshotsOnDemand === true; // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + workspaceSnapshotsOnDemand, + }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -3164,9 +3217,7 @@ export const layerWsRpc = ( })), ); const providerStatuses = Stream.zipLatestWith( - // The registry stream carries changes only. Seed it with the current - // providers so a source refresh that lands before any provider change - // still pairs up and reaches the client. + // Seed the changes-only stream so early source refreshes pair with current providers. Stream.concat( Stream.fromEffect(providerRegistry.getProviders), providerRegistry.streamChanges, @@ -3176,13 +3227,17 @@ export const layerWsRpc = ( usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, ), ), - (providers, sources) => - usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + (providers, sources) => { + const commandProviders = usageLimitsCommand + ? withUsageLimitsCommands(providers, sources) + : providers; + // Stripped before the de-dupe below, so snapshot-only changes send nothing. + return workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; + }, ).pipe( - // Both sides replay their current value, so the first pairing normally - // repeats the snapshot the client already holds. Compare against that - // snapshot rather than dropping blindly: a refresh that landed between - // the snapshot and the subscription still goes out. + // Compare initial paired values with the snapshot; blindly dropping them loses changes between snapshot and subscription. (updates) => Stream.concat(rpcInitialItems([config.providers]), updates), Stream.changesWith( (previous, next) => JSON.stringify(previous) === JSON.stringify(next), @@ -3195,10 +3250,7 @@ export const layerWsRpc = ( })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); - // Gated on the subscriber's capability flag because an - // already-shipped client decodes this stream against the old - // event union and its whole config subscription dies on an - // unknown member. + // Gate capability-specific events; older clients reject them and lose their config subscription. const environmentThemeUpdates = input.environmentThemes === true ? environmentTheme.streamChanges.pipe( @@ -3297,12 +3349,6 @@ export const layerWsRpc = ( ); }), ), - [WS_METHODS.subscribeBackgroundPolicy]: (_input) => - Stream.unwrap( - Effect.map(backgroundPolicy.subscribe, ({ latest, changes }) => - Stream.concat(Stream.make(latest), changes), - ), - ), [WS_METHODS.subscribeResourceTelemetry]: (_input) => Stream.unwrap( Effect.map(resourceTelemetry.subscribe, ({ latest, changes }) => @@ -3320,8 +3366,7 @@ export const layerWsRpc = ( }), ); -// RpcServer's default sends a socket-level Defect frame instead, and the client -// ends every pending request on the socket with it. +// Default socket-level Defect frames end every pending client request. export const WS_RPC_SERVER_OPTIONS = { disableTracing: true, disableFatalDefects: true, @@ -3332,18 +3377,23 @@ const provideWsRpcDependencies = ( handlers: Layer.Layer, services: { readonly sql: SqlClient.SqlClient; + readonly peerLinks: PeerLinks.PeerLinks["Service"]; + readonly peerLinkRequests: PeerLinkRequests.PeerLinkRequests["Service"]; readonly serverSelfUpdate: ServerSelfUpdate.ServerSelfUpdate["Service"]; readonly pullRequests: PullRequestService.PullRequestService["Service"]; readonly reviewGuides: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"]; }, ) => { - const { sql, serverSelfUpdate, pullRequests, reviewGuides } = services; + const { sql, serverSelfUpdate, pullRequests, reviewGuides, peerLinks, peerLinkRequests } = + services; return handlers.pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees // their defects, not the rest of the server's. Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), + Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), + Layer.provide(Layer.succeed(PeerLinkRequests.PeerLinkRequests, peerLinkRequests)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), @@ -3382,14 +3432,14 @@ export const makeInProcessWsRpcLayer = (session: EnvironmentAuth.AuthenticatedSe layerWsRpc( session, {}, - {}, - yield* PreviewAutomationBroker.PreviewAutomationBroker, yield* ServerBrowser.ServerBrowser, yield* McpGatewayBroker.McpGatewayBroker, yield* ClientFocusBroker.ClientFocusBroker, ), { sql: yield* SqlClient.SqlClient, + peerLinks: yield* PeerLinks.PeerLinks, + peerLinkRequests: yield* PeerLinkRequests.PeerLinkRequests, serverSelfUpdate: yield* ServerSelfUpdate.ServerSelfUpdate, pullRequests: yield* PullRequestService.PullRequestService, reviewGuides: yield* PullRequestReviewGuideService.PullRequestReviewGuideService, @@ -3401,13 +3451,14 @@ export const makeInProcessWsRpcLayer = (session: EnvironmentAuth.AuthenticatedSe export const layer = Layer.unwrap( Effect.gen(function* () { - const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverBrowser = yield* ServerBrowser.ServerBrowser; const mcpGatewayBroker = yield* McpGatewayBroker.McpGatewayBroker; const clientFocusBroker = yield* ClientFocusBroker.ClientFocusBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; + const peerLinks = yield* PeerLinks.PeerLinks; + const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", @@ -3439,9 +3490,7 @@ export const layer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); - const clientOrigin = readClientConnectionOrigin(request); const clientAnalyticsProps = readClientAnalyticsProps(request); - yield* sessions.recordClientConnection(session.sessionId, clientOrigin); const transport = NodeHttpServerRequest.toIncomingMessage(request).socket; const liveness = yield* WsClientLiveness.make(() => ({ pendingBytes: transport.writableLength, @@ -3490,21 +3539,18 @@ export const layer = Layer.unwrap( ), Effect.forkScoped, ); - // @effect-diagnostics-next-line returnEffectInGen:off return httpEffect; }).pipe( Effect.provide( provideWsRpcDependencies( layerWsRpc( session, - clientOrigin, clientAnalyticsProps, - previewAutomationBroker, serverBrowser, mcpGatewayBroker, clientFocusBroker, ), - { sql, serverSelfUpdate, pullRequests, reviewGuides }, + { sql, serverSelfUpdate, pullRequests, reviewGuides, peerLinks, peerLinkRequests }, ), ), ); diff --git a/apps/server/src/wsClientLiveness.ts b/apps/server/src/wsClientLiveness.ts index 5241401b6f040..957bc380f5975 100644 --- a/apps/server/src/wsClientLiveness.ts +++ b/apps/server/src/wsClientLiveness.ts @@ -20,16 +20,9 @@ export interface TransportProgress { export type ClientUnresponsiveReason = "client-silent" | "send-stalled"; -/** - * Watches one WebSocket RPC connection for a peer that is gone without closing. - * - * A suspended mobile app keeps its TCP connection open: the phone's kernel - * still acknowledges and advertises a zero window, so neither TCP nor the RPC - * layer notices, and on resume the app reuses a socket wedged behind a stale - * backlog. `awaitUnresponsive` completes once the client has sent nothing for - * {@link CLIENT_SILENCE_LIMIT} or the send queue has not moved for - * {@link SEND_STALL_LIMIT}, so the caller can reset the connection. - */ +/** Suspended phones keep TCP alive with a zero window, hiding stale sockets from TCP/RPC checks. + * awaitUnresponsive lets callers reset after CLIENT_SILENCE_LIMIT without inbound traffic + * or SEND_STALL_LIMIT without send-queue progress. */ export const make = Effect.fn("wsClientLiveness.make")(function* ( readProgress: () => TransportProgress, ) { diff --git a/apps/server/src/zipArchive.ts b/apps/server/src/zipArchive.ts index 33c8099473889..6711fbd3f5ee8 100644 --- a/apps/server/src/zipArchive.ts +++ b/apps/server/src/zipArchive.ts @@ -3,9 +3,7 @@ import * as Effect from "effect/Effect"; import type * as NodeStream from "node:stream"; import * as Yauzl from "yauzl"; -/** - * Close the scope to close the archive. - */ +/** Closing the scope closes the archive. */ export const openZipArchive = Effect.fn("zipArchive.open")(function* ( archivePath: string, makeError: (detail: string, cause: unknown) => E, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 6bbb3e5bf90be..3927b3212c9cf 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -29,15 +29,16 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { ); } -// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe -// step refuses multi-chunk output and counts the sourcemap as a chunk, and the -// executable needs a host Node that supports `--build-sea` (25.7+), so this is -// a separate mode rather than a second entry in the default build. -const packExecutable = process.env.T3CODE_PACK_EXE === "1"; +// tsdown SEA needs one chunk (sourcemaps count) and Node >=25.7, so package separately. +// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 +// "1" builds dist-exe/t3 here; "bundle" stops at bin.mjs for scripts/cli.ts to inject (linux-arm64). +const packMode = process.env.T3CODE_PACK_EXE; +const packExecutable = packMode === "1" || packMode === "bundle"; +// TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 +// No linux-arm64: --build-sea output cannot load on 16/64 KiB page kernels, so it builds natively. const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, "darwin-x64": { platform: "darwin", arch: "x64" }, - "linux-arm64": { platform: "linux", arch: "arm64" }, "linux-x64": { platform: "linux", arch: "x64" }, "win-arm64": { platform: "win", arch: "arm64" }, "win-x64": { platform: "win", arch: "x64" }, @@ -66,9 +67,11 @@ const packExecutableTargets = packExecutableTarget // The root config's `projects` route repo-root runs to package configs, // including this one. Inheriting them would make this config a container. const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; +// Workspace cache configuration is invalid on a package config. +const { run: _rootRunConfig, ...basePackageConfig } = baseConfig; export default mergeConfig( - { ...baseConfig, test: baseTestConfig }, + { ...basePackageConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { @@ -80,29 +83,27 @@ export default mergeConfig( }, }, pack: { - // The executable embeds one entry; the history worker becomes a hidden - // subcommand there instead of a sibling script. + // SEA embeds one entry; expose the history worker as a hidden subcommand. entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, clean: true, - ...(packExecutable + // TEMPORARY one-time Node fix. Remove when fixed: https://github.com/nodejs/node/issues/67048 + // SEA can only resolve built-ins; keep dynamic imports inside its one module. + ...(packExecutable ? { outputOptions: { codeSplitting: false } } : {}), + ...(packMode === "1" ? { exe: { fileName: "t3", outDir: "dist-exe", ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. + // Node SEA useCodeCache breaks import(), which the server needs. seaConfig: { useCodeCache: false }, }, } : {}), deps: { - // `neverBundle` forces the native packages out: returning false from - // `alwaysBundle` only means "no opinion", so a transitive dependency - // would still be bundled — which silently inlined native loaders such - // as node-gyp-build, losing native acceleration. + // alwaysBundle=false has no opinion; neverBundle must retain transitive native acceleration. alwaysBundle: shouldBundleCliDependency, neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, @@ -133,12 +134,13 @@ export default mergeConfig( }, }, test: { - // The server suite exercises sqlite, git, temp worktrees, and orchestration - // runtimes heavily. Running files in parallel introduces load-sensitive flakes. + // Parallel sqlite/git/worktree/orchestration tests introduce load-sensitive flakes. fileParallelism: false, sequence: { sequencer: WeightedShardSequencer }, - // Appended to the root setup, which mergeConfig concatenates. - setupFiles: ["./src/testUtils/gitConfig.setup.ts"], + setupFiles: [ + "./src/testUtils/gitConfig.setup.ts", + "./src/testUtils/managedServiceEnv.setup.ts", + ], hookTimeout: 120_000, testTimeout: 120_000, }, diff --git a/apps/web/index.html b/apps/web/index.html index e522b8c1fae1e..2b92d65808948 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,14 +6,14 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - +
    Research
    Results', + ); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelector("td")?.textContent).toBe("Research"); + expect(doc.images).toHaveLength(1); + expect(doc.images[0]!.getAttribute("src")).toBe("data:image/png;base64,iVBORw0KGgo="); + expect(doc.querySelector("script,style,iframe,a,[onerror]")).toBeNull(); + expect(html).not.toContain("https://"); + }); +}); +it("opens a known empty attachment even when its range response is inaccessible", async () => { + refresh.mockResolvedValue("https://unreachable.invalid/empty.txt"); + await using screen = await render( + , + ); + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file is empty."), + 10_000, + ); +}); + +it.each(["draft", "thread"] as const)( + "reads an unknown-size %s workspace file instead of treating it as empty", + async (source) => { + refresh.mockResolvedValue("https://example.test/binary.txt"); + using _fetch = stubGlobal( + "fetch", + vi.fn(async () => new Response("invalid\0text")), + ); + await using screen = await render( + , + ); + await vi.waitFor(() => + expect(screen.container.textContent).toContain( + "This file contains binary data and cannot be shown as text.", + ), + ); + expect(fetch).toHaveBeenCalled(); + expect(screen.container.textContent).not.toContain("This file is empty."); + }, +); + +it("refreshes authorization and retries a failed streamed attachment", async () => { + const bad = URL.createObjectURL(new Blob(["invalid\0text"])); + const good = URL.createObjectURL(new Blob(["recovered text\n"])); + refresh.mockReset().mockResolvedValueOnce(bad).mockResolvedValue(good); + try { + await using screen = await render( + , + ); + // A cold large-text worker can take longer than waitFor's default to boot on CI. + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file contains binary data."), + { timeout: 10_000 }, + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor(() => expect(screen.container.textContent).toContain("recovered text")); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + URL.revokeObjectURL(bad); + URL.revokeObjectURL(good); + } +}); + +it.each([ + ["custom/sheet1.xml", 250001], + ["/xl/custom/sheet1.bin", 250001], + ["custom/valid.xml", 1], +] as const)( + "bounds the worksheet relationship target %s at row %i before the reader allocates its grid", + async (target, row) => { + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + zip.file( + "xl/workbook.xml", + '', + ); + zip.file( + "xl/_rels/workbook.xml.rels", + ``, + ); + zip.file( + target.startsWith("/") ? target.slice(1) : `xl/${target}`, + `worksheet value`, + ); + const file = await zip.generateAsync({ type: "blob", compression: "DEFLATE" }); + const src = URL.createObjectURL(file); + try { + await using screen = await render( +
    + +
    , + ); + await vi.waitFor( + () => + expect(screen.container.textContent).toContain( + row > 250000 ? "This workbook exceeds the preview limit" : "worksheet value", + ), + { timeout: 10_000 }, + ); + } finally { + URL.revokeObjectURL(src); + } + }, +); + +it("copies every indexed line rather than the mounted preview rows", async () => { + const text = Array.from({ length: 10_000 }, (_, i) => `line-${i}`).join("\n"); + const copyContents = { current: null as (() => Promise) | null }; + await using screen = await render( + , + ); + await vi.waitFor(() => expect(screen.container.textContent).toContain("line-0")); + // Copy rejects until the worker finishes indexing, which a cold worker can delay past waitFor's default on CI. + await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text), { + timeout: 10_000, + }); + const first = copyContents.current!(); + await expect(copyContents.current!()).rejects.toThrow("Another copy is in progress."); + expect(await first).toBe(text); + expect(await copyContents.current!()).toBe(text); + expect(screen.container.querySelector('[role="alert"]')).toBeNull(); +}); + +it.each(["archive", "sqlite"] as const)( + "reauthorizes and retries a failed %s attachment", + async (kind) => { + const bad = URL.createObjectURL(new Blob(["invalid"])); + let bytes: Uint8Array; + if (kind === "archive") + bytes = new Uint8Array( + await new JSZip().file("recovered.txt", "ok").generateAsync({ type: "uint8array" }), + ); + else { + const [{ default: initSqlJs }, { default: wasmUrl }] = await Promise.all([ + import("sql.js"), + import("sql.js/dist/sql-wasm.wasm?url"), + ]); + const SQL = await initSqlJs({ locateFile: () => wasmUrl }); + const database = new SQL.Database(); + database.run( + "CREATE TABLE recovered (value TEXT); INSERT INTO recovered VALUES('retry succeeded')", + ); + bytes = new Uint8Array(database.export()); + database.close(); + } + const good = URL.createObjectURL(new Blob([bytes])); + refresh.mockReset().mockResolvedValueOnce(bad).mockResolvedValue(good); + try { + await using screen = await render( + , + ); + await vi.waitFor( + () => expect(screen.container.querySelector('[role="alert"] button')).not.toBeNull(), + 10_000, + ); + await act(() => + screen.container.querySelector('[role="alert"] button')!.click(), + ); + await vi.waitFor( + () => + expect(screen.container.textContent).toContain( + kind === "archive" ? "recovered.txt" : "retry succeeded", + ), + 10_000, + ); + expect(refresh).toHaveBeenCalledTimes(2); + } finally { + URL.revokeObjectURL(bad); + URL.revokeObjectURL(good); + } + }, +); + +it("loads a font attachment through the lazy preview", async () => { + const { default: fontUrl } = + await import("../../../node_modules/@betteroffice/fonts/assets/Carlito-Regular.ttf?url"); + const file = await (await fetch(fontUrl)).blob(); + await using screen = await render( + , + ); + // Two cold lazy chunks plus decoding a 600 KB font can take longer than waitFor's default on CI. + await vi.waitFor( + () => { + const frame = screen.container.querySelector("iframe"); + expect(frame?.title).toBe("Carlito.ttf"); + expect(frame?.srcdoc).toContain("@font-face"); + expect(frame?.srcdoc).toContain("The quick brown fox"); + }, + { timeout: 10_000 }, + ); + expect(screen.container.querySelector('[role="alert"]')).toBeNull(); +}); + +it("toggles a Mermaid attachment between diagram and source through the lazy preview", async () => { + const text = "flowchart LR\nA --> B"; + const file = new Blob([text], { type: "text/vnd.mermaid" }); + await using screen = await render( + , + ); + await vi.waitFor(() => + expect(screen.container.querySelector("[data-markdown-mermaid] svg path")).not.toBeNull(), + ); + await screen.getByRole("button", { name: "Expand diagram" }).click(); + await vi.waitFor(() => { + const image = document.querySelector('[role="dialog"] img'); + expect(image?.alt).toBe("Mermaid diagram"); + expect(image?.naturalWidth).toBeGreaterThan(0); + }); + await page.getByRole("button", { name: "Close image preview" }).click(); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + await screen.getByRole("button", { name: "Show Mermaid source" }).click(); + await vi.waitFor(() => expect(screen.container.querySelector("pre")?.textContent).toBe(text)); + await screen.getByRole("button", { name: "Show rendered diagram" }).click(); + await vi.waitFor(() => + expect(screen.container.querySelector("[data-markdown-mermaid] svg path")).not.toBeNull(), + ); + expect(screen.container.querySelector('[role="alert"]')).toBeNull(); +}); + +it("unwraps one text attachment without turning off the word wrap setting", async () => { + const file = new Blob(["a long line"], { type: "text/plain" }); + await using screen = await render( + , + ); + await screen.getByRole("button", { name: "Disable word wrap" }).click(); + await expect.element(screen.getByRole("button", { name: "Enable word wrap" })).toBeVisible(); + expect(getClientSettings().wordWrap).toBe(true); + expect(screen.container.querySelector("pre")?.dataset.wordWrap).toBe("false"); +}); + +it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { + const notebook = { + nbformat: 4, + metadata: {}, + cells: Array.from({ length: 20 }, (_, cell) => ({ + cell_type: "code", + source: [], + metadata: {}, + outputs: Array.from({ length: 100 }, (_, output) => ({ + output_type: "display_data", + data: { "text/html": `

    saved ${cell}-${output}

    ` }, + })), + })), + }; + const file = new Blob([JSON.stringify(notebook)]); + await using screen = await render( +
    + +
    , + ); + // The first outputs wait on the lazy notebook viewer and the conversion worker, not on a frame. + await vi.waitFor( + () => expect(screen.container.querySelectorAll("iframe").length).toBeGreaterThan(0), + { timeout: 10_000 }, + ); + expect(screen.container.querySelectorAll("iframe").length).toBeLessThanOrEqual(16); + const first = screen.container.querySelector("iframe")!; + expect(first.srcdoc).toContain("saved 0-0"); + const viewport = screen.container.querySelector(".scrollbar-gutter-both")!; + await act(() => { + viewport.scrollTop = viewport.scrollHeight; + }); + await vi.waitFor(() => { + expect( + [...screen.container.querySelectorAll("iframe")].some((frame) => + frame.srcdoc.includes("saved 19-99"), + ), + ).toBe(true); + expect(first.isConnected).toBe(false); + }); + expect(screen.container.querySelectorAll("iframe").length).toBeLessThanOrEqual(16); + await act(() => { + viewport.scrollTop = 0; + }); + await vi.waitFor(() => + expect(screen.container.querySelector("iframe")?.srcdoc).toContain("saved 0-0"), + ); +}); + +it.each([ + [String.raw`{\rtf1\uc1\u945{\b bold}Hello}`, "αboldHello"], + [String.raw`{\rtf1\uc1{\u945}Hello}`, "αHello"], + [ + String.raw`{\rtf1\ansi{\fonttbl{\f0\fcharset204 Cyrillic;}{\f1\fcharset0 Latin;}}\f0\'c0{\f1\'c0}\'c1}`, + "АÀБ", + ], + [String.raw`{\rtf1\ansi\deff1{\fonttbl{\f1\fcharset204 Cyrillic;}}\'c0\plain\'c1}`, "АБ"], +])("retains RTF grouped Unicode and font text in %s", async (source, expected) => { + const { openRtf } = await import("./extendedDocumentPreview"); + const result = openRtf(new TextEncoder().encode(source).buffer); + if (result.type !== "document") throw new Error("Expected rich text document"); + const html = result.pages.map((page) => page.html).join(""); + expect(new DOMParser().parseFromString(html, "text/html").body.textContent).toBe(expected); +}); + +it.each(["odt", "odp"] as const)("preserves rendered %s spaces and tabs", async (kind) => { + const archive = new JSZip().file( + "content.xml", + `${kind === "odt" ? "" : ""}ABC${kind === "odt" ? "" : ""}`, + ); + const bytes = await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind, bytes }, [bytes]); + }); + if (result.type !== "document") throw new Error("Expected document"); + await using screen = await render( + , + ); + await vi.waitFor(() => { + const doc = screen.container.querySelector("iframe")?.contentDocument; + const spans = doc?.querySelectorAll("span[data-odf-whitespace]"); + expect(doc?.querySelector("p")?.textContent).toBe("A B\tC"); + expect(spans).toHaveLength(2); + for (const span of spans!) { + expect(doc!.defaultView!.getComputedStyle(span).whiteSpace).toBe("pre-wrap"); + expect(span.getBoundingClientRect().width).toBeGreaterThan(0); + } + expect(spans![0]!.getBoundingClientRect().width).toBeGreaterThan(10); + }); + } finally { + worker.terminate(); + } +}); + +it.each(["rtf", "epub", "ods"] as const)( + "preserves %s document text through the worker and attachment viewer", + async (kind) => { + const archive = new JSZip(); + if (kind === "epub") { + archive.file( + "META-INF/container.xml", + '', + ); + archive.file( + "book.opf", + '', + ); + archive.file( + "chapter.xhtml", + "Handbook

    first
    second
    third


    ", + ); + } else if (kind === "ods") { + archive.file( + "content.xml", + 'Northnested noteprivate noterevised from 120', + ); + } + const file = + kind === "rtf" + ? new Blob([ + String.raw`{\rtf1\ansi 10\endash 20 \emdash \bullet \lquote a\rquote \ldblquote b\rdblquote\par Name\cell Value\cell\row 12\cell 34\cell\row{\*\private hidden\endash\cell\row}Unicode:\u8211\endash end}`, + ]) + : await archive.generateAsync({ type: "blob" }); + const bytes = await file.arrayBuffer(); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind, bytes }, [bytes]); + }); + if (kind === "ods") { + expect(result.type).toBe("ods"); + if (result.type !== "ods") throw new Error("Expected spreadsheet"); + expect(result.sheets[0]!.rows).toEqual([["North", "150"]]); + } else { + expect(result.type).toBe("document"); + if (result.type !== "document") throw new Error("Expected document"); + const body = new DOMParser().parseFromString(result.pages[0]!.html, "text/html").body; + if (kind === "epub") { + expect(body.querySelectorAll("br")).toHaveLength(2); + expect(body.querySelectorAll("hr")).toHaveLength(1); + } else { + expect(body.textContent).toContain("10–20 —•‘a’“b”"); + expect(body.textContent).toContain("Name\tValue\t12\t34\t"); + expect(body.textContent).toContain("Unicode:–end"); + expect(body.textContent).not.toContain("hidden"); + expect(body.querySelectorAll("br")).toHaveLength(3); + } + } + } finally { + worker.terminate(); + } + await using screen = await render( +
    + +
    , + ); + await vi.waitFor( + () => { + if (kind === "ods") { + expect(screen.container.textContent).toContain("North"); + expect(screen.container.textContent).toContain("150"); + expect(screen.container.textContent).not.toContain("private note"); + expect(screen.container.textContent).toContain("1 rows · 2 columns"); + expect(screen.container.textContent).not.toContain("Limited to 500"); + } else { + const frame = screen.container.querySelector("iframe"); + expect(frame?.srcdoc).toContain(kind === "rtf" ? "10–20" : "first
    second
    third"); + } + // The viewer runs its own conversion worker behind lazy chunks. + }, + { timeout: 10_000 }, + ); + expect(screen.container.querySelector('[role="alert"]')).toBeNull(); + }, +); + +it.each(["epub", "odt", "ipynb", "html", "encoded-html"] as const)( + "bounds %s image pixels before returning browser markup", + async (kind) => { + const canvas = new OffscreenCanvas(2, 3); + canvas.getContext("2d"); + const blob = await canvas.convertToBlob({ type: "image/png" }); + for (const width of [2, 12000]) { + const image = await blob.arrayBuffer(); + new DataView(image).setUint32(16, width); + new DataView(image).setUint32(20, width === 2 ? 3 : 12000); + const archive = new JSZip(); + archive.file("cover.png", image); + if (kind === "epub") { + archive.file( + "META-INF/container.xml", + '', + ); + archive.file( + "book.opf", + '', + ); + archive.file( + "chapter.xhtml", + '

    retained caption

    ', + ); + } else + archive.file( + "content.xml", + 'retained caption', + ); + const base64 = btoa(String.fromCharCode(...new Uint8Array(image))); + const bytes = + kind === "ipynb" || kind === "html" || kind === "encoded-html" + ? new TextEncoder().encode( + JSON.stringify({ + nbformat: 4, + cells: [ + { + cell_type: "code", + source: "plot()", + outputs: [ + { + data: { + ...(kind === "html" || kind === "encoded-html" + ? { + "text/html": `
    retained caption
    `, + } + : { "image/png": base64 }), + "text/plain": "retained caption", + }, + }, + ], + }, + ], + }), + ).buffer + : await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage( + { + type: "open", + kind: kind === "html" || kind === "encoded-html" ? "ipynb" : kind, + bytes, + }, + [bytes], + ); + }); + if (result.type === "notebook") { + const output = result.cells[0]!.outputs[0]!; + expect(output.kind).toBe( + width === 2 ? (kind === "html" || kind === "encoded-html" ? "html" : "image") : "text", + ); + if (output.kind === "html") { + const rendered = new DOMParser().parseFromString( + sanitizeDocumentHtml(output.text), + "text/html", + ); + expect(rendered.images).toHaveLength(1); + expect(rendered.querySelector("td")!.textContent).toBe("retained caption"); + } + if (width !== 2) expect(result.cells[0]!.outputs[0]!.text).toBe("retained caption"); + } else { + expect(result.type).toBe("document"); + if (result.type !== "document") throw new Error("Expected document"); + expect(result.pages[0]!.html.includes(" { + const { BinaryDataFactory } = await import("./pdfDocument"); + const factory = new BinaryDataFactory(); + const fetchAsset = vi.fn( + async () => + new Response("app", { + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + using _fetch = stubGlobal("fetch", fetchAsset); + await expect(factory.fetch({ kind, filename })).rejects.toThrow( + `PDF ${directory} asset unavailable.`, + ); + fetchAsset.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3]))); + await expect(factory.fetch({ kind, filename })).resolves.toEqual(new Uint8Array([1, 2, 3])); + await expect(factory.fetch({ kind, filename: "missing" })).rejects.toThrow( + `Unsupported PDF ${directory} asset.`, + ); +}); diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index 16b8c279023ba..1d8bc609ff56a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -1,24 +1,31 @@ import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { AssetResource, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; -import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; +import { + filePreviewKind, + officePreviewKind, + FILE_TEXT_PREVIEW_MAX_BYTES, +} from "@t3tools/shared/filePreview"; import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from "lucide-react"; import { Check, Code2, Copy, Eye, Table2 } from "lucide"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useAssetUrlRefresh } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; +import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter"; import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { BinaryFilePreview } from "./BinaryFilePreview"; +import { PreviewImage } from "./PreviewImage"; import { AudioPreview } from "./AudioPreview"; import { BrowserDocumentFrame } from "./BrowserDocumentFrame"; import { DelimitedTablePreview } from "./DelimitedTablePreview"; @@ -30,11 +37,25 @@ import { FileSurfaceNotice, } from "./fileSurfaceChrome"; +const LargeTextViewer = lazy(() => import("./LargeTextViewer")); + +const OfficeFilePreview = lazy(() => + import("./OfficeFilePreview").then((module) => ({ default: module.OfficeFilePreview })), +); + +const FontFilePreview = lazy(() => + import("./FontFilePreview").then((module) => ({ default: module.FontFilePreview })), +); + +const MermaidFilePreview = lazy(() => + import("./MermaidFilePreview").then((module) => ({ default: module.MermaidFilePreview })), +); + const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; -export function ReadOnlySourcePreview(props: { name: string; text: string }) { +export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -42,7 +63,11 @@ export function ReadOnlySourcePreview(props: { name: string; text: string }) { ); } -function renderedToggleLabel(mode: "markdown" | "html" | "table", rendered: boolean): string { +function renderedToggleLabel( + mode: "markdown" | "mermaid" | "html" | "table", + rendered: boolean, +): string { + if (mode === "mermaid") return rendered ? "Show Mermaid source" : "Show rendered diagram"; if (mode === "markdown") return rendered ? "Show markdown source" : "Show rendered markdown"; if (mode === "table") return rendered ? "Show source" : "Show table"; return rendered ? "Show HTML source" : "Show rendered page"; @@ -53,7 +78,12 @@ export function AttachmentFilePreview(props: { mimeType: string; sizeBytes: number; file?: Blob | null; - asset?: { environmentId: EnvironmentId; attachmentId: string }; + asset?: + | { environmentId: EnvironmentId; attachmentId: string } + | { + environmentId: EnvironmentId; + resource: Extract; + }; htmlRender?: boolean; threadRef?: ScopedThreadRef; origin?: string; @@ -61,22 +91,39 @@ export function AttachmentFilePreview(props: { onClose?: () => void; }) { const kind = filePreviewKind(props); + const officeKind = officePreviewKind(props); const delimiter = filePreviewDelimiter(props); const renderedMode = - kind === "markdown" ? "markdown" : kind === "html" ? "html" : delimiter ? "table" : null; - const attachmentId = props.asset?.attachmentId; + kind === "mermaid" + ? "mermaid" + : kind === "markdown" + ? "markdown" + : kind === "html" + ? "html" + : delimiter + ? "table" + : null; + const attachmentId = + props.asset && "attachmentId" in props.asset ? props.asset.attachmentId : undefined; const { copyToClipboard, isCopied } = useCopyToClipboard({ target: props.name }); // useAssetUrlRefresh keys its callback on these objects, so they must stay cached. - const resource = attachmentId - ? { - _tag: "attachment" as const, - attachmentId, - fileName: props.name, - mimeType: props.mimeType, - disposition: "inline" as const, - } + const resource = + props.asset && "resource" in props.asset + ? props.asset.resource + : attachmentId + ? { + _tag: "attachment" as const, + attachmentId, + fileName: props.name, + mimeType: props.mimeType, + disposition: "inline" as const, + } + : null; + const downloadResource = resource + ? resource._tag === "draft-workspace-file" + ? resource + : { ...resource, disposition: "attachment" as const } : null; - const downloadResource = resource ? { ...resource, disposition: "attachment" as const } : null; const refresh = useAssetUrlRefresh( props.file ? null : (props.asset?.environmentId ?? null), props.file ? null : resource, @@ -90,7 +137,9 @@ export function AttachmentFilePreview(props: { const [content, setContent] = useState<{ text: string; truncated: boolean } | null>(null); const [error, setError] = useState(null); const [contentError, setContentError] = useState(null); + const copyContents = useRef<(() => Promise) | null>(null); const authorizedAt = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates an object URL for the local file and revokes it on cleanup useEffect(() => { if (!props.file) return; const url = URL.createObjectURL(props.file); @@ -98,6 +147,7 @@ export function AttachmentFilePreview(props: { setLocalUrl(url); return () => URL.revokeObjectURL(url); }, [props.file]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- authorizes a remote attachment URL from the server and ignores the result after cleanup useEffect(() => { if (props.file) return; let cancelled = false; @@ -121,9 +171,15 @@ export function AttachmentFilePreview(props: { // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retry must reauthorize the remote file. }, [props.file, refresh, revision]); const url = props.file ? localUrl : remoteUrl; - const needsText = kind === "text" || kind === "markdown" || (kind === "html" && !rendered); + const streamsText = + kind === "text" && + (props.sizeBytes > FILE_TEXT_PREVIEW_MAX_BYTES || + (props.sizeBytes === 0 && (!resource || resource._tag === "attachment"))); + const needsText = + kind === "text" || kind === "markdown" || kind === "mermaid" || (kind === "html" && !rendered); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fetches the file's text from its URL or Blob stream and aborts the request on cleanup useEffect(() => { - if (!needsText || !url) return; + if (!needsText || streamsText || !url) return; const controller = new AbortController(); // oxlint-disable-next-line react/set-state-in-effect -- A new external resource must clear the previous response before loading. setContent(null); @@ -156,15 +212,17 @@ export function AttachmentFilePreview(props: { setContentError(cause instanceof Error ? cause.message : "Could not load this file."); }); return () => controller.abort(); - }, [url, needsText, revision, props.sizeBytes, props.file, refresh]); + }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && - content !== null && + (content !== null || streamsText) && !(delimiter && rendered) && - !(kind === "markdown" && rendered); + !((kind === "markdown" || kind === "mermaid") && rendered); const save = () => { setSaving(true); @@ -177,8 +235,7 @@ export function AttachmentFilePreview(props: { if (!response.ok) throw new Error("The file could not be loaded. Try again."); file = await response.blob(); } - // A Blob keeps cross-origin downloads inside the desktop client instead of - // navigating its custom app scheme to an external browser. + // Blob downloads keep desktop's custom app scheme from navigating to an external browser. const downloadUrl = URL.createObjectURL(file); // Scheduled before the click so the URL is revoked even if the click throws. setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000); @@ -207,18 +264,60 @@ export function AttachmentFilePreview(props: { setRevision((value) => value + 1); }} /> + ) : streamsText && url ? ( + }> + setRevision((value) => value + 1)} + delimiter={rendered ? delimiter : null} + copyContents={copyContents} + wordWrap={wordWrap} + /> + ) : !url || (needsText && !content) ? ( ) : needsText && content ? ( delimiter && rendered ? ( + ) : kind === "mermaid" && rendered ? ( + }> + + ) : kind === "markdown" && rendered ? ( - + ) : ( - + ) + ) : kind === "archive" || kind === "sqlite" ? ( + setRevision((value) => value + 1)} + /> + ) : kind === "font" ? ( + }> + + + ) : officeKind ? ( + }> + + ) : kind === "pdf" || kind === "html" ? ( ) : kind === "image" ? ( - // oxlint-disable-next-line t3code/require-centered-scroll-gutter -- The image is capped at max-h-full max-w-full, so this never scrolls. -
    - {props.name} setError("Unable to load image.")} - /> -
    + setError("Unable to load image.")} + /> ) : (

    No preview for this file

    @@ -293,15 +390,38 @@ export function AttachmentFilePreview(props: { updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > ) : null} - {content ? ( + {content || streamsText ? ( copyToClipboard(content.text, undefined)} + label={isCopied ? "Copied" : content?.truncated ? "Copy preview" : "Copy contents"} + onPress={() => { + if (content) { + void copyToClipboard(content.text, undefined); + return; + } + const copy = copyContents.current; + if (!copy) { + toastManager.add({ + type: "error", + title: "Could not copy file", + description: "Wait for the file to finish loading before copying.", + }); + return; + } + void copy() + .then((text) => copyToClipboard(text, undefined)) + .catch((cause: unknown) => + toastManager.add({ + type: "error", + title: "Could not copy file", + description: cause instanceof Error ? cause.message : "Try again.", + }), + ); + }} > diff --git a/apps/web/src/components/files/AudioPreview.tsx b/apps/web/src/components/files/AudioPreview.tsx index 23d0d2db15c3f..0a8f4bcfe16c9 100644 --- a/apps/web/src/components/files/AudioPreview.tsx +++ b/apps/web/src/components/files/AudioPreview.tsx @@ -1,12 +1,38 @@ -import { useState } from "react"; +import { LoaderCircleIcon, PauseIcon, PlayIcon, TriangleAlertIcon } from "lucide-react"; +import { createContext, useContext, useRef, useState } from "react"; -export function AudioPreview(props: { +import { useStableCallback } from "~/hooks/useStableCallback"; +import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; +import { middleTruncateAttachmentName } from "../composerInlineChip"; + +export const AudioPreviewUnmountContext = createContext<((audio: HTMLAudioElement) => void) | null>( + null, +); + +type AudioPreviewProps = { readonly src: string; readonly name: string; - /** Re-signed URLs with the same revision keep the loaded audio and its position. */ readonly revision?: string | null; readonly onError?: () => void; -}) { + readonly compact?: boolean; + readonly failed?: boolean; + readonly onOpen?: (() => void) | undefined; + readonly onRetry?: () => void; +}; + +export function AudioPreview(props: AudioPreviewProps) { + return props.compact ? ( + + ) : ( + + ); +} + +function StandardAudioPreview(props: AudioPreviewProps) { + const onUnmount = useContext(AudioPreviewUnmountContext); + const audioRef = useStableCallback((audio: HTMLAudioElement | null) => { + if (audio) return () => onUnmount?.(audio); + }); const [loadedSource, setLoadedSource] = useState<{ src: string; revision: string } | null>(null); const [playing, setPlaying] = useState(false); const revision = props.revision ?? null; @@ -18,6 +44,7 @@ export function AudioPreview(props: { return (