diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ccfeca966dfa..bf28b1b342d2 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,4 +1,5 @@ reviews: + high_level_summary: false review_status: false auto_review: enabled: true diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 6781356cf58d..9df6050347e8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -68,6 +68,7 @@ github:realAhmedRoach github:Rishet11 github:ryanrhughes github:saphid +github:scratchyone github:sethwebster github:shiroyasha9 github:shivamhwp diff --git a/.github/triage/PLAYBOOK.md b/.github/triage/PLAYBOOK.md index 39bf3ea01052..32def6bc0c16 100644 --- a/.github/triage/PLAYBOOK.md +++ b/.github/triage/PLAYBOOK.md @@ -59,8 +59,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (`server.trace.ndjson`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (`server.trace.ndjson`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9de774a2e3ee..ab2280b8e703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,6 @@ jobs: - name: Check run: vp check - - name: Check className overrides on components/ui do not grow - run: vp run lint:restyle-ceiling - - name: Typecheck run: vpr typecheck diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index a3f89b615c2b..6209661e5e97 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -12,8 +12,15 @@ on: paths: - "infra/relay/**" - ".github/workflows/deploy-relay.yml" + # Alchemy does not redeploy the Worker when only a Config value read in its + # Init changes (alchemy-run/alchemy#1831), so a changed repository variable + # needs a forced deploy to reach production. workflow_dispatch: inputs: + force: + description: Redeploy every resource, including ones with no detected changes + type: boolean + default: true skip_external_migrations: description: >- Skip the external Postgres migrations step. Use only when the runner cannot reach the @@ -34,6 +41,8 @@ concurrency: jobs: deploy_relay: name: Deploy production relay + # A manual run from another branch would deploy that branch to production. + if: github.ref == 'refs/heads/turbo' runs-on: ubuntu-24.04 timeout-minutes: 15 environment: @@ -51,6 +60,7 @@ jobs: RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} + RELAY_TUNNEL_CLEANUP_MODE: ${{ vars.RELAY_TUNNEL_CLEANUP_MODE }} CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} # Optional Android push; without FCM_SERVICE_ACCOUNT the FCM resources stay off. @@ -121,7 +131,7 @@ jobs: - name: Deploy production relay stage if: steps.cloudflare_config.outputs.enabled == 'true' - run: vp run --filter t3code-relay deploy --stage prod --yes --no-input + run: vp run --filter t3code-relay deploy --stage prod --yes --no-input ${{ inputs.force && '--force' || '' }} env: # The PublishClientConfig action writes the client env here instead # of the repo-root .env; nothing on the runner reads it. diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index e22d21f4788b..91fa1c235f79 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -78,7 +78,19 @@ jobs: working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: eas env:pull preview --non-interactive + run: | + eas env:pull preview --non-interactive + # EAS Update disables dotenv loading. Keep the downloaded native config + # in its process environment so Android build and OTA fingerprints match. + node --input-type=module <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + import { parseEnv } from "node:util"; + const env = parseEnv(readFileSync(".env.local", "utf8")); + const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; + if (googleServicesFile) { + appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); + } + NODE - name: Deploy with fingerprint check if: steps.expo-token.outputs.present == 'true' diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index ab7cd4a5f0a0..958738276a9e 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -76,6 +76,17 @@ jobs: echo "$vp_pnpm_bin" >> "$GITHUB_PATH" "$vp_pnpm_bin/pnpm" --version + - name: Install AXe + # Locks the simulator and answers the notification prompt for the + # agent-activity scene. + # Homebrew refuses third-party tap formulae until they are trusted; + # older releases have no trust command and install them as is. + run: | + brew tap cameroncooke/axe + if brew commands | grep -qx trust; then brew trust --formula cameroncooke/axe/axe; fi + brew install cameroncooke/axe/axe + axe --version + - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c374a8692db6..c743b961560b 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -510,6 +510,7 @@ jobs: "release/*.dmg" "release/*.zip" "release/*.AppImage" + "release/*.deb" "release/*.exe" ) # Preview builds have no publish config, so electron-builder writes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b74b167761d..162263d18691 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -317,13 +317,76 @@ jobs: - name: Typecheck run: vp run typecheck + # Keep tests on their own runners, using the same package split and server + # shards as CI, so the release check job does not spend its budget on tests. + test: + name: Release tests + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - uses: ./.github/actions/setup-apt-mirrors - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config build-essential + + - name: Test + run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + + test_server: + name: Release server tests ${{ matrix.shard }} + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + # No Electron setup here: `t3` (apps/server) has no Electron dependency + # and none of its tests touch the runtime. Only the non-server `test` + # job, which covers @t3tools/desktop, needs the download. - name: Test - run: vp run test + run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }} relay_public_config: name: Resolve T3 Connect public config @@ -675,7 +738,7 @@ jobs: # No relay stack, no tracing artifact to download. relay_client_tracing: ${{ needs.preflight.outputs.connect_enabled == 'true' }} label: Linux arm64 - runner: ubuntu-24.04-arm + runner: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-16vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }} platform: linux target: AppImage arch: arm64 @@ -881,6 +944,7 @@ jobs: echo 'release-assets/*.dmg' echo 'release-assets/*.zip' echo 'release-assets/*.AppImage' + echo 'release-assets/*.deb' echo 'release-assets/*.exe' if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then echo 'release-assets/*.blockmap' diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index e38c0b040af0..66ee81306a5a 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-5-6-sol -effort: medium +model: gpt-6-sol +effort: max input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index b90c81ab0a49..ab37120ff344 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-5-6-sol -effort: medium +model: gpt-6-sol +effort: max input: incremental tools: - browse_code diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 57aea73d7873..94db28b7c7d5 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -1175,6 +1175,10 @@ { "path": "apps/web/src/components/chat/providerIconUtils.ts", "markers": ["[ProviderDriverKind.make(\"openrouter\")]: OpenRouterIcon,"] + }, + { + "path": "apps/server/src/provider/model-manifest.json", + "markers": ["\"driver\": \"openrouter\""] } ] }, @@ -1206,21 +1210,6 @@ } ] }, - { - "id": "restyle-ceiling-fork-delta", - "status": "implemented", - "summary": "Upstream's shadcn no-restyle ceiling counts upstream code only; the fork's own UI adds a fixed number of className overrides, expressed as UPSTREAM_RESTYLE_CEILING + TURBO_RESTYLE_OVERRIDES so upstream changes to the ceiling merge cleanly.", - "checks": [ - { - "path": "scripts/lint-restyle-ceiling.ts", - "markers": [ - "export const UPSTREAM_RESTYLE_CEILING = ", - "export const TURBO_RESTYLE_OVERRIDES = 6;", - "export const RESTYLE_CEILING = UPSTREAM_RESTYLE_CEILING + TURBO_RESTYLE_OVERRIDES;" - ] - } - ] - }, { "id": "release-from-turbo-branch", "status": "policy", diff --git a/AGENTS.md b/AGENTS.md index 7c4229c8663f..bb10431bca9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,7 +240,7 @@ Full glossary with file links: `docs/internals/glossary.md` ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. -- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits, add a variant to the component, not classes at the call site. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` reports violations and CI caps their count. +- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. - Inferred types over annotations. `any` is the enemy. - Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. - 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. diff --git a/README.md b/README.md index f30b9dc95102..9bb934ec710e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ winget install T3Tools.T3Code brew install --cask t3-code ``` +#### Debian, Ubuntu (`.deb`) + +Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then: + +```bash +sudo apt install ./T3-Code-*.deb +``` + #### Arch Linux (AUR) Stable: diff --git a/SEAM.md b/SEAM.md index 4fdafb2850cc..cdd4db57d186 100644 --- a/SEAM.md +++ b/SEAM.md @@ -22,9 +22,10 @@ fork's change. groups. - **Optional** `infra/relay/alchemy.run.ts` — provider layers and tracing outputs exist only when configured. -- **Tuned** `scripts/lint-restyle-ceiling.ts` — `RESTYLE_CEILING = UPSTREAM_RESTYLE_CEILING + -TURBO_RESTYLE_OVERRIDES` (6 fork className overrides on components/ui exports). On conflict, - take upstream's new number into `UPSTREAM_RESTYLE_CEILING` and keep the sum. +- **Retired 2026-09-29** `scripts/lint-restyle-ceiling.ts` (seam `restyle-ceiling-fork-delta`) — + upstream #13210 deleted the ceiling gate and made `shadcn/no-restyle` a lint error in + `vite.config.ts`. There is no ceiling left to tune; fork UI must use variants/sizes like upstream + code. On conflict, do not resurrect the script. - **Additive** `infra/relay/scripts/apply-external-migrations.ts` (+ test) — applies `infra/relay/migrations/postgres` to the self-hosted Supabase Postgres from `deploy-relay.yml` before `alchemy deploy`, one transaction per folder, ledger table `relay_external_migrations`, @@ -367,6 +368,9 @@ Built to survive orchestrator v2 (pingdotgg/t3code#2829): `components/chat/providerIconUtils.ts`, `components/settings/providerDriverMeta.ts`, `session-logic.ts` (picker option), `composerDraftStore.ts` (provider option keys), and `lib/contextWindow.ts` (display name). +- **Additive** `apps/server/src/provider/model-manifest.json` — an `openrouter` bundled + compatibility policy mirroring `claudeAgent` (the driver probes the same `claude` binary), so + every built-in harness has a bundled policy. - **Additive, V1-shim (retires at v2 cutover)** `Drivers/OpenRouterDriver.ts` — the `ProviderDriver` registration. v2's `ClaudeAdapterV2` already imports the same `makeClaudeEnvironment`/`mergeProviderInstanceEnvironment` plumbing and accepts per-instance @@ -376,7 +380,8 @@ Built to survive orchestrator v2 (pingdotgg/t3code#2829): driver identity on events and sessions, keeping the churn-heavy file merge-clean. On a nightly-sync conflict: everything here is additive except `builtInDrivers.ts`, -`settings.ts`/`model.ts` map entries, and the six web wiring files above — re-add the fork lines +`settings.ts`/`model.ts` map entries, the `model-manifest.json` policy entry, and the six web +wiring files above — re-add the fork lines after upstream's. If upstream ships its own OpenRouter or the ACP registry (#6071) covers it, prefer upstream and retire the shim first. @@ -536,7 +541,7 @@ settles "do nothing". Two fork-owned constants, no product behavior change: - **`packages/client-runtime/src/state/shellSnapshotHttp.ts`** - `DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS` - is 30s (upstream: 6s) and exported. The cached shell still renders while the request runs. + is 30s (upstream: 6s, raised to 20s and made private in the 2026-09-30 ingest) and exported. The cached shell still renders while the request runs. - **`apps/server/src/project/RepositoryIdentityResolver.ts`** - `DEFAULT_POSITIVE_CACHE_TTL` is 12h and `DEFAULT_NEGATIVE_CACHE_TTL` is 10min (upstream: 1min each), both exported. A repository's remote does not change minute to minute, the identity is cosmetic (PR links, icons), and a diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2fc62f3adce4..9bd6a34965c7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,9 +1,9 @@ { "name": "@t3tools/desktop", - "version": "0.0.53", + "version": "0.0.54", "private": true, "type": "module", - "main": "dist-electron/main.cjs", + "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 28d8e37d7a9e..678b62f58aac 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -1,3 +1,4 @@ +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -108,3 +109,65 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, await NodeFSP.rm(directory, { recursive: true, force: true }); } }); + +it("loads the emitted packaged boot entry and backend cache preload", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); + try { + const entries = ["src/boot.ts", "src/compileCache.ts"]; + await NodeFSP.mkdir(NodePath.join(directory, "src")); + await Promise.all( + entries.map((entry) => + NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), + ), + ); + assert.ok(Array.isArray(desktopConfig.pack)); + for (const packConfig of desktopConfig.pack) { + if (!Array.isArray(packConfig.entry)) continue; + if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; + await build({ + ...packConfig, + config: false, + cwd: directory, + tsconfig: false, + sourcemap: false, + onSuccess: undefined, + logLevel: "silent", + }); + } + const outputDirectory = NodePath.join(directory, "dist-electron"); + const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; + await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); + await NodeFSP.writeFile( + NodePath.join(outputDirectory, "backend.mjs"), + `import { getCompileCacheDir } from 'node:module'; console.log(getCompileCacheDir() ? 'cached' : 'uncached');`, + ); + for (const disabled of [false, true]) { + for (const args of [ + [NodePath.join(outputDirectory, "boot.cjs")], + [ + "--require", + NodePath.join(outputDirectory, "compileCache.cjs"), + NodePath.join(outputDirectory, "backend.mjs"), + ], + ]) { + const child = NodeChildProcess.spawnSync(process.execPath, args, { + encoding: "utf8", + env: { + ...process.env, + APPIMAGE: "", + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + XDG_CACHE_HOME: directory, + TMPDIR: directory, + TEMP: directory, + TMP: directory, + }, + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached"); + } + } + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index fea5f0a120e5..05195bb8d8cd 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ 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/main.cjs"); +const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/src/app/CodexAuthCallback.test.ts b/apps/desktop/src/app/CodexAuthCallback.test.ts new file mode 100644 index 000000000000..6d8a905885ac --- /dev/null +++ b/apps/desktop/src/app/CodexAuthCallback.test.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Tests exercise the real native loopback listener without an OpenAI account. +import * as NodeHttp from "node:http"; +import { describe, expect, it } from "vite-plus/test"; +import { codexAuthDeliveryUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { receiveCodexAuthCallback, cancelCodexAuthCallback } from "./CodexAuthCallback.ts"; + +async function freePort() { + 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())); + return address.port; +} +function request(port: number, state = "a".repeat(43)) { + const url = new URL("https://auth.openai.com/api/accounts/authorize"); + url.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://127.0.0.1:${port}/auth/callback`, + state, + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + return url.toString(); +} +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.toString(); +} + +describe("desktop Codex callback helper", () => { + it("binds before opening sign-in, ignores a foreign response, and returns only the code callback", async () => { + const authorizationUrl = request(await freePort()); + const expected = callback(authorizationUrl); + const received = await receiveCodexAuthCallback(authorizationUrl, async () => { + const invalid = new URL(expected); + invalid.searchParams.set("state", "foreign"); + expect((await fetch(invalid)).status).toBe(400); + const response = await fetch(expected); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.text()).not.toContain("test-code"); + return true; + }); + expect(received).toBe(expected); + }); + it("returns hosted web to the exact instance and environment without putting the code in its query", async () => { + const authorizationUrl = request(await freePort()); + const expected = callback(authorizationUrl); + const input = { + authorizationUrl, + returnUrl: "https://app.t3.codes/settings/providers?environmentId=remote-one&instanceId=work", + environmentId: EnvironmentId.make("remote-one"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + await receiveCodexAuthCallback( + authorizationUrl, + async () => { + const response = await fetch(expected, { redirect: "manual" }); + expect(response.status).toBe(303); + const delivery = response.headers.get("location")!; + expect(new URL(delivery).searchParams.has("code")).toBe(false); + expect(readCodexAuthDelivery(delivery)?.callbackUrl).toBe(expected); + expect(readCodexAuthDelivery(delivery)?.returnUrl).toBe(input.returnUrl); + return true; + }, + (url) => codexAuthDeliveryUrl(input, url), + ); + }); + it("cancels and releases its listener so exact-port reauthorization can run again", async () => { + const authorizationUrl = request(await freePort()); + await expect( + receiveCodexAuthCallback(authorizationUrl, async () => { + cancelCodexAuthCallback(authorizationUrl); + return true; + }), + ).rejects.toThrow("cancelled"); + expect( + await receiveCodexAuthCallback(authorizationUrl, async () => { + await fetch(callback(authorizationUrl)); + return true; + }), + ).toBe(callback(authorizationUrl)); + }); + it("allows two accounts to complete independently", async () => { + const a = request(await freePort(), "a".repeat(43)); + const b = request(await freePort(), "c".repeat(43)); + const openedA = Promise.withResolvers(); + const openedB = Promise.withResolvers(); + const receiveA = receiveCodexAuthCallback(a, async () => { + openedA.resolve(); + await openedB.promise; + await fetch(callback(a)); + return true; + }); + const receiveB = receiveCodexAuthCallback(b, async () => { + openedB.resolve(); + await openedA.promise; + await fetch(callback(b)); + return true; + }); + expect(await Promise.all([receiveA, receiveB])).toEqual([callback(a), callback(b)]); + }); +}); diff --git a/apps/desktop/src/app/CodexAuthCallback.ts b/apps/desktop/src/app/CodexAuthCallback.ts new file mode 100644 index 000000000000..a2fda9ab68cc --- /dev/null +++ b/apps/desktop/src/app/CodexAuthCallback.ts @@ -0,0 +1,5 @@ +export { + CodexAuthCallbackError, + cancelCodexAuthCallback, + receiveCodexAuthCallback, +} from "@t3tools/shared/codexAuthCallback"; diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index d6ce80322798..8ab165afce0a 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -44,6 +44,25 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva }; } +function startOkServer(target: ReturnType, userId: number | undefined) { + return startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => ({ + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }), + cancel: () => undefined, + onReclaimError: () => undefined, + }).then((server) => { + openServers.push(server); + return server; + }); +} + function exchange(address: string, payload: DesktopAppActivationRequest) { return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); @@ -84,6 +103,7 @@ describe("desktop app control server", () => { }; }, cancel: () => undefined, + onReclaimError: () => undefined, }); openServers.push(server); @@ -117,6 +137,7 @@ describe("desktop app control server", () => { userId, handle: () => new Promise(() => undefined), cancel: resolveCanceled, + onReclaimError: () => undefined, }); openServers.push(server); const socket = NodeNet.createConnection(target.address); @@ -137,4 +158,50 @@ describe("desktop app control server", () => { }); }), ); + + // Two desktop apps can share one state dir, such as nightly and a preview build. + it.effect("keeps a newer app's socket when an older app on the same state dir quits", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + if (platform === "win32") return; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const older = await startOkServer(target, userId); + await startOkServer(target, userId); + + await older.close(); + + await expect( + exchange(target.address, request("after-quit", platform)), + ).resolves.toMatchObject({ ok: true, requestId: "after-quit" }); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("binds its address again after the socket file is removed", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + if (platform === "win32") return; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const server = await startOkServer(target, userId); + + await NodeFSP.unlink(target.address); + await server.reclaim(); + + await expect( + exchange(target.address, request("reclaimed", platform)), + ).resolves.toMatchObject({ + ok: true, + requestId: "reclaimed", + }); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 50fc70d783e4..740799955da2 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,7 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, @@ -44,6 +47,8 @@ export class DesktopAppActivationStartError extends Schema.TaggedError Promise; readonly close: () => Promise; } @@ -70,12 +75,12 @@ function requestIdFromUnknown(value: unknown): string { return "invalid-request"; } -async function prepareUnixSocket(input: { - readonly address: string; +/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */ +async function prepareUnixDirectory(input: { readonly directory: string; readonly userId: number | undefined; -}): Promise { - await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); +}): Promise { + const created = await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); const stat = await NodeFSP.lstat(input.directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`${input.directory} is not a directory.`); @@ -84,28 +89,43 @@ async function prepareUnixSocket(input: { throw new Error(`${input.directory} is owned by another user.`); } await NodeFSP.chmod(input.directory, 0o700); - await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); + return created !== undefined; +} + +async function inodeAt(path: string): Promise { + return NodeFSP.lstat(path).then( + (stat) => stat.ino, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }, + ); +} + +function closeServer(server: NodeNet.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); } +/** + * Serves `t3 app` requests on the local control address until `close`. + * + * Two desktop apps can share one state dir, for example nightly and a preview + * build. They share one socket path, so on Unix: + * - The newest app takes the path over. + * - `close` removes the socket file only while it is still this app's socket. + * - An app binds the path again when it is gone, for example after the app that + * took it over quits. + */ export async function startDesktopAppControlServer(input: { readonly address: string; readonly directory: string | null; readonly userId: number | undefined; readonly handle: (request: DesktopAppActivationRequest) => Promise; readonly cancel: (requestId: string) => void; + readonly onReclaimError: (error: unknown) => void; }): Promise { - if (input.directory !== null) { - await prepareUnixSocket({ - address: input.address, - directory: input.directory, - userId: input.userId, - }); - } - const sockets = new Set(); - const server = NodeNet.createServer((socket) => { + const handleConnection = (socket: NodeNet.Socket) => { sockets.add(socket); socket.setEncoding("utf8"); let buffer = ""; @@ -160,40 +180,116 @@ export async function startDesktopAppControlServer(input: { sockets.delete(socket); if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); }); - }); + }; - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - server.removeListener("listening", onListening); - reject(error); - }; - const onListening = () => { - server.removeListener("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(input.address); - }); + const listen = (address: string) => + new Promise((resolve, reject) => { + const server = NodeNet.createServer(handleConnection); + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(server); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(address); + }); - try { - if (input.directory !== null) { - await NodeFSP.chmod(input.address, 0o600); + // 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. `rename` takes the address over in one step. `link` claims + // it only while it is free, and fails with EEXIST otherwise. + const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { + const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); + const server = await listen(staging); + try { + await NodeFSP.chmod(staging, 0o600); + const inode = await inodeAt(staging); + if (mode === "take-over") { + await NodeFSP.rename(staging, input.address); + } else { + await NodeFSP.link(staging, input.address); + await NodeFSP.unlink(staging); + } + return { server, inode }; + } catch (error) { + await closeServer(server); + throw error; } - } catch (error) { - await new Promise((resolve) => server.close(() => resolve())); - throw error; + }; + + 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 }); + ({ server, inode } = await bindUnix(input.directory, "take-over")); } let closed = false; + const reclaimOnce = async () => { + // Never replace a socket that exists, so two apps cannot trade the path back and forth. + 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. + watchDirectory(input.directory); + } + const next = await bindUnix(input.directory, "claim-free").catch( + (error: NodeJS.ErrnoException) => { + // Another app bound the address first. + if (error.code === "EEXIST") return null; + throw error; + }, + ); + if (next === null) return; + const previous = server; + ({ server, inode } = next); + previous.close(); + }; + let pendingReclaim = Promise.resolve(); + const reclaim = () => { + const run = pendingReclaim.then(reclaimOnce); + pendingReclaim = run.catch(() => undefined); + return run; + }; + let watcher: NodeFS.FSWatcher | null = null; + const watchDirectory = (directory: string) => { + watcher?.close(); + watcher = null; + try { + watcher = NodeFS.watch(directory, { persistent: false }, () => { + reclaim().catch(input.onReclaimError); + }); + watcher.on("error", input.onReclaimError); + } catch (error) { + // The socket still works without a watcher. It only cannot recover after removal. + input.onReclaimError(error); + } + }; + if (input.directory !== null) { + watchDirectory(input.directory); + // Catch a removal that happened before the watcher started. + reclaim().catch(input.onReclaimError); + } + return { + reclaim, close: async () => { if (closed) return; closed = true; + // A running reclaim can replace the watcher, so close the watcher after it. + await pendingReclaim; + watcher?.close(); for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); + await closeServer(server); server.removeAllListeners(); - if (input.directory !== null) { + if (inode !== null && (await inodeAt(input.address)) === inode) { await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); @@ -258,6 +354,10 @@ export const make = Effect.gen(function* () { userId, handle: (request) => broker.request(request), cancel: (requestId) => broker.cancel(requestId), + onReclaimError: (cause) => + void runPromise( + logWarning("failed to restore the desktop app control socket", { cause }), + ), }), catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), }), diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 40e9bf00c0dd..8312e7870490 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -76,7 +76,7 @@ const makeAssetsLayer = (png: Option.Option) => icns: Option.none(), png, }), - resolveResourcePath: () => Effect.succeed(Option.none()), + resolveResourcePath: () => Effect.succeedNone, } satisfies DesktopAssets.DesktopAssets["Service"]); const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index e3edecc1a1f2..da300cfde762 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,3 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeHttp from "node:http"; +import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -22,14 +27,24 @@ 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"; +import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { +const makeDesktopClerkLayer = ( + isDevelopment = true, + events: string[] = [], + shell: ElectronShell.ElectronShell["Service"] = { + openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(false), + copyText: () => Effect.void, + }, +) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ stateDir: "/tmp/t3-state", isDevelopment, @@ -51,6 +66,7 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { Layer.mergeAll( Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), + Layer.succeed(ElectronShell.ElectronShell, shell), FileSystem.layerNoop({ exists: () => Effect.succeed(false) }), ), ), @@ -162,7 +178,7 @@ describe("DesktopClerk", () => { assert.isTrue(Exit.isSuccess(exit)); assert.equal(quit.mock.calls.length, 0); - assert.deepEqual(registeredEvents, ["second-instance"]); + assert.deepEqual(registeredEvents, ["open-url", "second-instance"]); }).pipe( Effect.provide(makeDesktopClerkLayer()), Effect.provideService(ElectronApp.ElectronApp, electronApp), @@ -198,3 +214,131 @@ describe("DesktopClerk", () => { ); }); }); + +it.effect( + "provider auth deep links navigate and reveal the running desktop without handling Clerk URLs", + () => { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const listeners = new Map void>(); + const revealed = Promise.withResolvers(); + const loadURL = vi.fn(async (_url: string) => undefined); + const window = { loadURL }; + const electronApp = { + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + const electronWindow = { + currentMainOrFirst: Effect.succeed(Option.some(window)), + reveal: () => Effect.sync(() => revealed.resolve()), + } as unknown as ElectronWindow.ElectronWindow["Service"]; + return Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, "t3code-dev://app/auth/callback?code=clerk-code"); + listeners.get("open-url")!(event, "t3code://app/welcome"); + assert.equal(loadURL.mock.calls.length, 0); + assert.equal(event.preventDefault.mock.calls.length, 0); + listeners.get("second-instance")!({}, [ + "t3", + "t3code-dev://app/settings/providers?instanceId=work&code=never-forward", + ]); + yield* Effect.promise(() => revealed.promise); + assert.deepEqual(loadURL.mock.calls, [ + ["t3code-dev://app/settings/providers?instanceId=work"], + ]); + listeners.get("open-url")!(event, "t3code-dev://app/welcome#agents:machine-id"); + assert.equal(event.preventDefault.mock.calls.length, 1); + }).pipe( + Effect.scoped, + Effect.provide(makeDesktopClerkLayer()), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), + ); + }, +); + +for (const entry of ["startup", "open-url"] as const) { + it.effect(`receives hosted web sign-in through the desktop ${entry} handler`, () => + Effect.gen(function* () { + storageMock.mockReturnValue(storageAdapter); + createClerkBridgeMock.mockReturnValue({ cleanup: vi.fn(), isPrimaryInstance: true }); + const port = yield* Effect.promise(async () => { + 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())); + return address.port; + }); + const authorize = new URL("https://auth.openai.com/api/accounts/authorize"); + authorize.search = new URLSearchParams({ + client_id: "dynamic_agent_client", + response_type: "code", + redirect_uri: `http://127.0.0.1:${port}/auth/callback`, + state: "a".repeat(43), + code_challenge_method: "S256", + code_challenge: "b".repeat(43), + }).toString(); + const request = { + authorizationUrl: authorize.toString(), + returnUrl: "https://app.t3.codes/welcome#agents:remote-one", + environmentId: EnvironmentId.make("remote-one"), + instanceId: ProviderInstanceId.make("work"), + flowId: "flow-one", + }; + const link = codexAuthHandoffUrl(request, true); + const delivered = Promise.withResolvers(); + const shell = ElectronShell.ElectronShell.of({ + openExternal: (value) => + Effect.promise(async () => { + const url = new URL(String(value)); + const callback = new URL(url.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + state: url.searchParams.get("state")!, + code: "test-code", + client_id: "oaiapp_test", + }).toString(); + const response = await fetch(callback, { redirect: "manual" }); + delivered.resolve(response.headers.get("location")!); + return true; + }), + openSystemSettings: () => Effect.succeed(false), + copyText: () => Effect.void, + }); + const listeners = new Map void>(); + const electronApp = { + whenReady: Effect.void, + on: (name: string, listener: (...args: unknown[]) => void) => + Effect.sync(() => { + listeners.set(name, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]; + yield* Effect.gen(function* () { + const clerk = yield* DesktopClerk.DesktopClerk; + yield* clerk.configure; + if (entry === "open-url") { + const event = { preventDefault: vi.fn() }; + listeners.get("open-url")!(event, link); + assert.strictEqual(event.preventDefault.mock.calls.length, 1); + } + const delivery = readCodexAuthDelivery(yield* Effect.promise(() => delivered.promise)); + assert.strictEqual(delivery?.environmentId, request.environmentId); + assert.strictEqual(delivery?.instanceId, request.instanceId); + assert.strictEqual(delivery?.flowId, request.flowId); + assert.strictEqual(delivery?.returnUrl, request.returnUrl); + }).pipe( + Effect.provide(makeDesktopClerkLayer(true, [], shell)), + Effect.provideService(HostProcessArguments, entry === "startup" ? ["t3", link] : ["t3"]), + Effect.provideService(ElectronApp.ElectronApp, electronApp), + Effect.provideService( + ElectronWindow.ElectronWindow, + {} as ElectronWindow.ElectronWindow["Service"], + ), + ); + }).pipe(Effect.scoped), + ); +} diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 26054b74440f..61be28c811c6 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -7,6 +7,11 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { codexAuthDeliveryUrl, readCodexAuthHandoff } from "@t3tools/shared/codexAuthHandoff"; +import { receiveCodexAuthCallback, CodexAuthCallbackError } from "./CodexAuthCallback.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; +import { HostProcessArguments } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; @@ -87,6 +92,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; + const shell = yield* ElectronShell.ElectronShell; // Electron scopes the single-instance lock to the userData directory and // creates that directory when the lock is acquired. The SDK bridge takes @@ -136,13 +142,62 @@ export const make = Effect.gen(function* () { return yield* Effect.interrupt; } - yield* electronApp.on("second-instance", () => { + const startProviderAuthHandoff = (value: string | undefined) => { + if (!value) return false; + const request = readCodexAuthHandoff(value, environment.isDevelopment); + if (!request) return false; + void runPromise( + Effect.gen(function* () { + yield* electronApp.whenReady; + yield* Effect.tryPromise({ + try: () => + receiveCodexAuthCallback( + request.authorizationUrl, + (url) => runPromise(shell.openExternal(url)), + (callbackUrl) => codexAuthDeliveryUrl(request, callbackUrl), + ), + catch: () => + new CodexAuthCallbackError({ + detail: + "Could not receive hosted web ChatGPT sign-in. Retry or use the redirect URL in the web app.", + }), + }); + }).pipe( + Effect.catch(() => Effect.logWarning("Could not complete ChatGPT desktop handoff.")), + ), + ); + return true; + }; + const resumeProviderAuth = (value: string | undefined) => { + const destination = providerAuthReturnUrl(value); + const expectedOrigin = `${ElectronProtocol.getDesktopScheme(environment.isDevelopment)}://app`; + if (!destination?.startsWith(`${expectedOrigin}/`)) return false; + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isNone(mainWindow)) return; + yield* Effect.promise(() => mainWindow.value.loadURL(destination)); + yield* electronWindow.reveal(mainWindow.value); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not return to provider setup", cause), + ), + ), + ); + return true; + }; + const args = yield* HostProcessArguments; + args.some((value) => startProviderAuthHandoff(value)); + yield* electronApp.on("open-url", (event: { preventDefault: () => void }, url: string) => { + if (startProviderAuthHandoff(url) || resumeProviderAuth(url)) event.preventDefault(); + }); + yield* electronApp.on("second-instance", (_event: unknown, argv: readonly string[]) => { + if (argv?.some((value) => startProviderAuthHandoff(value) || resumeProviderAuth(value))) + return; void runPromise( Effect.gen(function* () { const mainWindow = yield* electronWindow.currentMainOrFirst; - if (Option.isSome(mainWindow)) { - yield* electronWindow.reveal(mainWindow.value); - } + if (Option.isSome(mainWindow)) yield* electronWindow.reveal(mainWindow.value); }), ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index fdc69841343c..5cb7aafbbd20 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -46,7 +46,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref return decoded.slice("encrypted:".length); }); }, - selectedStorageBackend: Effect.succeed(Option.none()), + selectedStorageBackend: Effect.succeedNone, } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index e2e0cd413bd9..46b2546e681c 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -205,7 +205,7 @@ const readDocument = ( raw === null ? Effect.succeed(Option.none()) : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index bb380b70a856..8fbf9609f95e 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service< readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; + // Preload that turns on the V8 compile cache for the local backend. + readonly compileCachePath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; readonly devRemoteT3ServerEntryPath: Option.Option; @@ -225,6 +227,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), + compileCachePath: path.join(input.dirname, "compileCache.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") : path.join(input.appPath, "dev-app-update.yml"), diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index f0b88101587c..893c1aaebe1d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,23 +1,29 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; interface RecordedRegistration { readonly directories: string[]; readonly files: Array<{ readonly path: string; readonly content: string }>; readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; + readonly copies: Array<{ readonly source: string; readonly destination: string }>; } -const makeEnvironment = (overrides: Record = {}) => +const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", isPackaged: true, @@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record = {}) => linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), - path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + path, ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); -const mockProcess = (exitCode: number) => +const mockProcess = (exitCode: number, stalled = false) => ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -50,16 +56,41 @@ const makeHandlerLayer = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly updateDesktopDatabaseExitCode?: number; + readonly updateDesktopDatabaseStalled?: boolean; + readonly updateDesktopDatabaseStarted?: Deferred.Deferred; readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; + readonly iconSource?: string; + readonly iconCopyError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Path.Path.pipe( + Effect.map((path) => makeEnvironment(path, input.environment)), + Effect.provide(Path.layer), + ), + ), + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + png: Option.fromUndefinedOr(input.iconSource), + ico: Option.none(), + icns: Option.none(), + }), + resolveResourcePath: () => Effect.succeedNone, + }), FileSystem.layerNoop({ + copyFile: (source, destination) => + input.iconCopyError + ? Effect.fail(input.iconCopyError) + : Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -79,11 +110,31 @@ const makeHandlerLayer = ( readonly command: string; readonly args: ReadonlyArray; }; + if (childProcess.command === "update-desktop-database") { + assert.isTrue( + recorded.files.length > 0 || input.existingEntry !== undefined, + "the desktop entry must exist before refreshing the MIME cache", + ); + } recorded.commands.push({ command: childProcess.command, args: childProcess.args, }); - return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + const exitCode = + childProcess.command === "update-desktop-database" + ? (input.updateDesktopDatabaseExitCode ?? 0) + : (input.xdgMimeExitCode ?? 0); + const handle = mockProcess( + exitCode, + childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStalled === true, + ); + return childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStarted + ? Deferred.succeed(input.updateDesktopDatabaseStarted, undefined).pipe( + Effect.as(handle), + ) + : Effect.succeed(handle); }), ), ), @@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({ directories: [], files: [], commands: [], + copies: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Nightly)", execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', scheme: "t3code", + iconPath: "/home/al ice/icons/T3\\x.png", }); assert.include(entry, "[Desktop Entry]"); @@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); it("carries structured context on registration errors", () => { @@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => { ); }); - it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { - const recorded = emptyRecording(); + it.effect( + "writes the handler entry, refreshes the MIME cache, and claims the scheme default", + () => { + const recorded = emptyRecording(); - return Effect.gen(function* () { - yield* runRegister(recorded); + return Effect.gen(function* () { + yield* runRegister(recorded); - assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); - assert.equal(recorded.files.length, 1); - assert.equal( - recorded.files[0]?.path, - "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", - ); - assert.include( - recorded.files[0]?.content, - 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', - ); - assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); - assert.deepEqual(recorded.commands, [ - { - command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], - }, - ]); - }); - }); + assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }, + ); it.effect("falls back to the process executable outside an AppImage", () => { const recorded = emptyRecording(); @@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Alpha)", execTarget: "/home/alice/Applications/T3-Code.AppImage", scheme: "t3code", + iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", }), }); assert.deepEqual(recorded.files, []); assert.deepEqual(recorded.directories, []); - assert.equal(recorded.commands.length, 1); + assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }); + + it.effect("installs a persistent icon even when the desktop entry is already current", () => { + const recorded = emptyRecording(); + const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, + }), + }); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.copies, [ + { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath }, + ]); + assert.equal(recorded.commands.at(-1)?.command, "xdg-mime"); + }); + }); + + it.effect("still registers the handler when copying its icon fails", () => { + const recorded = emptyRecording(); + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + iconCopyError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "copyFile", + description: "read-only icon directory", + }), + }); + assert.equal(recorded.files.length, 1); + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); }); }); @@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => { }); it.effect("never fails startup when registration cannot complete", () => { + const desktopDatabaseFailed = emptyRecording(); const xdgMimeFailed = emptyRecording(); const writeFailed = emptyRecording(); return Effect.gen(function* () { + yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 }); yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); yield* runRegister(writeFailed, { writeError: PlatformError.systemError({ @@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => { }), }); + assert.deepEqual( + desktopDatabaseFailed.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); assert.equal(xdgMimeFailed.files.length, 1); assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () => + Effect.gen(function* () { + const recorded = emptyRecording(); + const started = yield* Deferred.make(); + const registration = yield* runRegister(recorded, { + updateDesktopDatabaseStalled: true, + updateDesktopDatabaseStarted: started, + }).pipe(Effect.forkChild); + + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 404aff34c6bf..f01e81d890bc 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -8,6 +8,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; +import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -17,9 +18,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // 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. Instead, write -// our own handler entry pointing at the current AppImage and claim the -// scheme default via xdg-mime, exactly what the file manager's "set as -// default" checkbox would record in mimeapps.list. +// our own handler entry pointing at the current AppImage, refresh the desktop +// MIME cache so desktop environments recognize that entry as a handler, and +// use xdg-mime to record it as the scheme default in mimeapps.list. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -40,6 +41,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError< const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); +export class DesktopLinuxUrlHandlerCacheRefreshError extends Schema.TaggedError()( + "DesktopLinuxUrlHandlerCacheRefreshError", + { + applicationsDir: Schema.String, + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = + this.exitCode === undefined ? "" : `, update-desktop-database exit code ${this.exitCode}`; + return `Failed to refresh the desktop MIME cache at ${this.applicationsDir}${exitCode}.`; + } +} + +const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError); + const escapeDesktopEntryString = (value: string): string => value .replaceAll("\\", "\\\\") @@ -63,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string { return escapeDesktopEntryString(`"${quoted}"`); } -// The AppImage integration entry owns the window identity and icon. This +// The AppImage integration entry owns the window identity. This // hidden URL-only entry must not compete with it for StartupWMClass matching. export function renderUrlHandlerDesktopEntry(input: { readonly displayName: string; readonly execTarget: string; readonly scheme: string; + readonly iconPath?: string; }): string { return [ "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", "StartupNotify=false", @@ -95,12 +115,15 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const assets = yield* DesktopAssets.DesktopAssets; const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); const desktopEntryPath = environment.path.join( environment.linuxApplicationsDir, environment.linuxDesktopEntryName, ); + const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons"); + const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`); const writeDesktopEntry = Effect.gen(function* () { // Inside the mounted AppImage, process.execPath points at a transient @@ -110,6 +133,7 @@ export const make = Effect.gen(function* () { displayName: environment.displayName, execTarget, scheme, + ...(environment.isPackaged ? { iconPath } : {}), }); // Pre-ready setup normally wrote this already. Avoid truncating a valid // entry while the portal may be reading it during startup. @@ -131,6 +155,37 @@ export const make = Effect.gen(function* () { ), ); + const updateDesktopDatabase = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "update-desktop-database", + [environment.linuxApplicationsDir], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds")); + if (exitCode !== 0) { + return yield* new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + exitCode, + }); + } + }), + ).pipe( + Effect.mapError((error) => + isCacheRefreshError(error) + ? error + : new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + cause: error, + }), + ), + ); + const setDefaultHandler = Effect.scoped( Effect.gen(function* () { const command = ChildProcess.make( @@ -170,6 +225,33 @@ export const make = Effect.gen(function* () { } yield* writeDesktopEntry; if (!environment.isPackaged) return; + + yield* Effect.gen(function* () { + const { png } = yield* assets.iconPaths; + if (Option.isNone(png)) return; + // The AppImage mount is temporary; the chooser needs the icon after exit. + yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); + yield* fileSystem.copyFile(png.value, iconPath); + }).pipe( + Effect.catch((error) => + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), + ), + ); + + 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. + Effect.catch((error) => + logWarning("desktop MIME cache refresh failed", { + applicationsDir: environment.linuxApplicationsDir, + message: error.message, + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + ); + yield* setDefaultHandler; yield* logInfo("registered URL scheme handler", { scheme }); }).pipe( diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index d7ccfc43b185..e23d78aa2161 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -92,6 +93,9 @@ const collectorLayer = (requests: Array) => ), ); +// A developer's own OTEL_* variables would otherwise pick the endpoints. +const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const encodeObservabilitySettingsFile = Schema.encodeSync( Schema.fromJsonString( Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), @@ -181,7 +185,7 @@ describe("DesktopObservability", () => { assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -259,7 +263,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -299,7 +303,7 @@ describe("DesktopObservability", () => { assert.equal(records.at(-1)?.annotations.details, "code=1"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -343,7 +347,7 @@ describe("DesktopObservability", () => { assert.isFalse(text.includes("y")); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -377,7 +381,7 @@ describe("DesktopObservability", () => { assert.equal(lines.length, 258); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -423,7 +427,166 @@ describe("DesktopObservability", () => { assert.lengthOf(record?.events ?? [], 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), + ); + }); + + it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel export").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.strictEqual(request?.headers["x-otel"], "desktop"); + assert.strictEqual(request?.headers["x-scope"], undefined); + assert.strictEqual(request?.headers["content-type"], "application/json"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + }, + }), + ), + ), + ), + ); + }); + + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop service name").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-desktop"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ), + ); + }); + + it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel off").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }, + }), + ), + ), + ), + ); + }); + + it.effect("exports kill switch warnings through the configured logger", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.void.pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), + ), + ), ); }); @@ -464,7 +627,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -488,7 +651,40 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), + ); + }); + + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + yield* writeObservabilitySettings(environmentLayer, { + otlpTracesUrl: "https://settings.example.com/v1/traces", + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log stays local when disabled").pipe( + Effect.withSpan("desktop-disabled-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + ), + ), ); }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 96657215748b..ce8233e56612 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,7 +3,9 @@ import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, + type SignalExport, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -349,16 +351,51 @@ const readPersistedObservabilitySettings: Effect.Effect< }); /** - * Settings is read once for every signal, so the main process cannot - * resolve traces against one revision of the file and logs against another. + * Resolved as the server resolves them, with persisted Settings as the + * fallback. Settings is read once for every signal, so the main process + * cannot resolve traces against one revision of the file and logs against + * another. */ const resolveOtlpEndpoints = Effect.gen(function* () { + const otel = yield* OtelEnvironment.load; + if (otel.disabled) { + return { + traces: undefined, + metrics: undefined, + logs: undefined, + warnings: otel.warnings, + resourceAttributes: otel.resourceAttributes, + }; + } + const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; + const signalExport: SignalExport = { + protocol: environment.otlpProtocol, + headers: Option.getOrUndefined(environment.otlpHeaders), + exportIntervalMs: environment.otlpExportIntervalMs, + }; return { - traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, - metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, - logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + traces: OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport }, + persisted.otlpTracesUrl, + ), + metrics: OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport }, + persisted.otlpMetricsUrl, + ), + logs: OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport }, + persisted.otlpLogsUrl, + ), + warnings: otel.warnings, + resourceAttributes: otel.resourceAttributes, }; }); @@ -588,11 +625,10 @@ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - const headers = Option.getOrUndefined(environment.otlpHeaders); - const serializationLayer = otlpSerializationLayer(environment.otlpProtocol); const resource = { - serviceName: "desktop", + serviceName: "t3code-desktop", attributes: { + "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", }, @@ -615,14 +651,19 @@ const telemetryLayer = Layer.unwrap( : [ Logger.consolePretty(), OtlpLogger.make({ - url: endpoints.logs, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.logs.url, + exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`, + headers: endpoints.logs.export.headers, resource, }), ], { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer)); + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide( + otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol), + ), + ); const tracerLayer = Layer.unwrap( Effect.gen(function* () { @@ -637,11 +678,11 @@ const telemetryLayer = Layer.unwrap( endpoints.traces === undefined ? undefined : yield* OtlpTracer.make({ - url: endpoints.traces, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.traces.url, + exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`, + headers: endpoints.traces.export.headers, resource, - }).pipe(Effect.provide(serializationLayer)); + }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -665,13 +706,21 @@ const telemetryLayer = Layer.unwrap( // endpoints.metrics === undefined // ? Layer.empty // : OtlpMetrics.layer({ - // url: endpoints.metrics, - // exportInterval: `${environment.otlpExportIntervalMs} millis`, - // headers, + // url: endpoints.metrics.url, + // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, + // headers: endpoints.metrics.export.headers, // resource, - // }).pipe(Layer.provide(serializationLayer)); + // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol))); - return Layer.mergeAll(loggerLayer, tracerLayer); + // Logged once the loggers above are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe( + Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)), + Layer.provide(OtelEnvironment.layerResourceAttributes(endpoints.resourceAttributes)), + ); }), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index ef216adbcbec..8d3fd9a18adc 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -13,6 +13,7 @@ const { setDesktopNameMock, mkdirSyncMock, writeFileSyncMock, + copyFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -21,12 +22,15 @@ const { setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), + copyFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, getVersion: () => "0.0.37", + isPackaged: true, + getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, getSwitchValue: getSwitchValueMock, @@ -42,8 +46,10 @@ vi.mock("node:fs", () => ({ readFileSync: () => "{}", mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, + copyFileSync: copyFileSyncMock, })); +import { resolveLinuxDesktopEntryName } from "./DesktopEarlyElectronStartup.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; describe("DesktopPreReadyPlatform", () => { @@ -55,6 +61,7 @@ describe("DesktopPreReadyPlatform", () => { setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); + copyFileSyncMock.mockReset(); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -83,8 +90,13 @@ describe("DesktopPreReadyPlatform", () => { vi.stubEnv("XDG_DATA_HOME", "/xdg"); vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); getSwitchValueMock.mockReturnValue(""); + const expectedIconPath = `/xdg/icons/${resolveLinuxDesktopEntryName(false)}.png`; let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; + let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === expectedIconPath; + }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); @@ -94,7 +106,11 @@ describe("DesktopPreReadyPlatform", () => { return Effect.scoped( Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry })); + const portalIdentity = Promise.resolve().then(() => ({ + desktopName, + desktopEntry, + iconInstalled, + })); yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), @@ -105,6 +121,8 @@ describe("DesktopPreReadyPlatform", () => { assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); assert.include(identity.desktopEntry ?? "", "Name=T3 Turbo"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include(identity.desktopEntry ?? "", `Icon=${expectedIconPath}`); + assert.isTrue(identity.iconInstalled); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }, @@ -123,6 +141,20 @@ describe("DesktopPreReadyPlatform", () => { ); }); + it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { + getSwitchValueMock.mockReturnValue(""); + copyFileSyncMock.mockImplementation(() => { + throw new Error("missing bundled icon"); + }); + return Effect.gen(function* () { + yield* DesktopPreReadyPlatform.make; + const contents = writeFileSyncMock.mock.calls[0]?.[1]; + assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, "Icon="); + assert.equal(setDesktopNameMock.mock.calls.length, 1); + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + }); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..9b73a5d825bf 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -68,6 +68,28 @@ export const make = Effect.gen(function* () { "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); + const iconPath = Electron.app.isPackaged + ? NodePath.posix.join( + applicationsDir, + "..", + "icons", + `${linux.linuxDesktopEntryName}.png`, + ) + : undefined; + if (iconPath !== undefined) { + try { + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); + NodeFS.copyFileSync( + NodePath.posix.join( + Electron.app.getAppPath(), + "apps/desktop/prod-resources/icon.png", + ), + iconPath, + ); + } catch { + // Icon installation is optional; registration retries after readiness. + } + } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), renderUrlHandlerDesktopEntry({ @@ -77,6 +99,7 @@ export const make = Effect.gen(function* () { }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), + ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 4289ef881eef..2998f6d8f4e8 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -235,6 +235,11 @@ describe("DesktopBackendConfiguration", () => { const second = yield* configuration.resolvePrimary; assert.equal(first.executablePath, process.execPath); + assert.deepEqual(first.args.slice(0, 3), [ + "--require", + environment.compileCachePath, + environment.backendEntryPath, + ]); assert.equal(first.entryPath, environment.backendEntryPath); assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); @@ -776,12 +781,11 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); // No settings.json exists here: the endpoints come from the desktop - // process's env, which a WSL child cannot inherit, so the bootstrap - // has to carry them or log export stays off inside the distro. + // environment, and the bootstrap carries them for a WSL child that + // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -928,6 +932,8 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); + // Dev never shares the prod compile cache. + assert.notInclude(config.args, "--require"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -947,6 +953,116 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl carries the kill switch into the distro", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousDisabled = process.env.OTEL_SDK_DISABLED; + try { + delete process.env.WSLENV; + process.env.OTEL_SDK_DISABLED = "true"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OTEL_SDK_DISABLED", previousDisabled); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const standard = { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", + T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", + }; + const previousWslEnv = process.env.WSLENV; + // A developer's own OTLP variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter( + ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), + ); + try { + for (const [name] of ambientOtel) delete process.env[name]; + delete process.env.WSLENV; + Object.assign(process.env, standard); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal( + config.env.OTEL_EXPORTER_OTLP_ENDPOINT, + "https://collector.example.com:4318/base?api_key=secret", + ); + assert.equal( + config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, + "authorization=Bearer%20token", + ); + // Without a flag, WSL passes the values through untranslated. + const wslEnv = (config.env.WSLENV ?? "").split(":"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); + assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); + assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + for (const name of Object.keys(standard)) delete process.env[name]; + restoreEnv("WSLENV", previousWslEnv); + for (const [name, value] of ambientOtel) restoreEnv(name, value); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -959,7 +1075,10 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; + // A developer's own OTEL_* variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { + for (const [name] of ambientOtel) delete process.env[name]; process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; @@ -1016,6 +1135,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); + for (const [name, value] of ambientOtel) restoreEnv(name, value); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d670e91076b3..853c539f9968 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -90,14 +90,37 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL is handled -// separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. +// across the wsl.exe boundary without WSLENV. The dev-server URL travels as +// the `--dev-url` CLI flag instead. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + // 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. + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", + "OTEL_TRACES_EXPORTER", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -196,11 +219,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( Effect.as(Option.none()), ), @@ -218,12 +241,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap is the only channel that carries an OTLP endpoint to every -// backend. A Windows-native child inherits the desktop process's env, but a -// WSL child gets nothing across wsl.exe that WSLENV does not declare, and -// WSLENV translation of URL-shaped values is unreliable, so the endpoints are -// deliberately not forwarded that way. Env beats the persisted settings file, -// matching the precedence resolveServerConfig and DesktopObservability apply. +// The bootstrap carries the OTLP endpoints to every backend, including a WSL +// child that lacks the variables. The T3 URLs also travel as variables in +// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats +// the persisted settings file, matching the precedence resolveServerConfig and +// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -547,7 +569,16 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - args: [environment.backendEntryPath, "--bootstrap-fd", "3"], + // 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. + args: [ + ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), + environment.backendEntryPath, + "--bootstrap-fd", + "3", + ], entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { @@ -730,10 +761,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. Inheriting through - // WSLENV is unreliable in practice (URL-shaped values with colons / - // slashes get translated unpredictably depending on flags), and the - // packaged build leaves devServerUrl as None anyway. + // config resolution lands in dev/ instead of userdata/. The packaged build + // leaves devServerUrl as None. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index df2001f1ac20..901d9f4a2708 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -162,7 +162,7 @@ function makeTestInstance(input: MakeInstanceInput) { forInstance: () => Effect.succeed(stubLog), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { - latest: Effect.succeed(Option.none()), + latest: Effect.succeedNone, changes: Stream.empty, encoded: input.desktopTelemetryStream ?? Stream.empty, handleControlForSource: () => Effect.void, @@ -1545,7 +1545,7 @@ describe("DesktopBackendManager", () => { const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { list: Effect.succeed([instance1, instance2]), - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, primary: Effect.die(new Error("primary not implemented")), register: () => Effect.die(new Error("register not implemented")), unregister: () => Effect.die(new Error("unregister not implemented")), diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 7859223161b7..8373437ae312 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -67,7 +67,7 @@ function makePoolLayer( } satisfies DesktopObservability.DesktopBackendOutputLogShape), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { - latest: Effect.succeed(Option.none()), + latest: Effect.succeedNone, changes: Stream.empty, encoded: Stream.empty, handleControlForSource: () => Effect.void, @@ -135,9 +135,7 @@ describe("DesktopBackendPool", () => { it.effect("layerTest dies when no instances are supplied", () => Effect.exit( - Effect.gen(function* () { - yield* DesktopBackendPool.DesktopBackendPool; - }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef140..ed71bd332d24 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,7 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -60,7 +59,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), - currentConfig: Effect.succeed(Option.some(config)), + currentConfig: Effect.succeedSome(config), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts new file mode 100644 index 000000000000..c9eb35b24e5f --- /dev/null +++ b/apps/desktop/src/boot.ts @@ -0,0 +1,4 @@ +// Packaged app entry. 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 new file mode 100644 index 000000000000..20f942db7db9 --- /dev/null +++ b/apps/desktop/src/compileCache.ts @@ -0,0 +1,28 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Runs before any Effect runtime exists, so it stays on Node built-ins. +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +// Turns on Node's on-disk V8 code cache for every module loaded after this one, +// so later launches skip recompiling the large main and server bundles. +// Packaged builds only: boot.ts loads it for the main process, and the local +// backend gets it with `--require`. Dev launches main.cjs directly and skips it. +// 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. The backend inherits APPIMAGE, so this covers it too. +try { + if (!process.env.APPIMAGE) { + const cacheRoot = + // oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime. + process.platform === "linux" + ? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache") + : NodeOS.tmpdir(); + NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); + } +} catch { + // The cache is only a speedup. Never let it stop the app from starting. +} diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ce9e0fb48979..401e0c27ccd8 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -80,6 +80,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), + ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}), }; if (sourceItem.children) { @@ -168,6 +169,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, enabled: !item.disabled, + ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); @@ -224,7 +226,7 @@ export const make = Effect.gen(function* () { Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + resume(Effect.succeedNone); return; } diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 508a5c296898..0c2cb2bb7d17 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -105,7 +105,7 @@ describe("ElectronProtocol", () => { ); assert.include( response.headers.get("content-security-policy") ?? "", - "connect-src 'self' http: https: ws: wss:", + "connect-src 'self' blob: http: https: ws: wss:", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -255,7 +255,14 @@ describe("ElectronProtocol", () => { "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); - assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); + assert.deepEqual(directives["connect-src"], [ + "'self'", + "blob:", + "http:", + "https:", + "ws:", + "wss:", + ]); assert.deepEqual(directives["img-src"], [ "'self'", "t3code:", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 96cd462bd156..30feb85043f8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -84,7 +84,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat // 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. - const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ "default-src 'self'", @@ -125,6 +126,9 @@ 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. + codeCache: true, }, }, { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 58ba07f6fd31..45b6b1da41c1 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect"; +import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; @@ -134,6 +135,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(receiveProviderAuthCallback); + yield* ipc.handle(cancelProviderAuthCallback); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); yield* ipc.handle(pasteAsText); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index d765e24eefbc..e9053c6ff85e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -11,6 +11,7 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; @@ -117,3 +118,6 @@ export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"; export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input"; + +export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback"; +export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; diff --git a/apps/desktop/src/ipc/methods/providerAuth.ts b/apps/desktop/src/ipc/methods/providerAuth.ts new file mode 100644 index 000000000000..fbde6a409f3f --- /dev/null +++ b/apps/desktop/src/ipc/methods/providerAuth.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import { + receiveCodexAuthCallback, + cancelCodexAuthCallback, + CodexAuthCallbackError, +} from "../../app/CodexAuthCallback.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +const Request = Schema.String.check(Schema.isMaxLength(16_384)); + +export const receiveProviderAuthCallback = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, + payload: Request, + result: Schema.String, + handler: Effect.fn("desktop.ipc.providerAuth.receive")(function* (authorizationUrl) { + const shell = yield* ElectronShell.ElectronShell; + const windows = yield* ElectronWindow.ElectronWindow; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const callbackUrl = yield* Effect.tryPromise({ + try: () => + receiveCodexAuthCallback(authorizationUrl, (url) => runPromise(shell.openExternal(url))), + catch: () => + new CodexAuthCallbackError({ + detail: + "Could not receive ChatGPT sign-in on this computer. Try again or paste the redirect URL.", + }), + }); + const window = yield* windows.currentMainOrFirst; + if (Option.isSome(window)) yield* windows.reveal(window.value); + return callbackUrl; + }), +}); + +export const cancelProviderAuthCallback = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, + payload: Request, + result: Schema.Void, + handler: (authorizationUrl) => + Effect.try({ + try: () => cancelCodexAuthCallback(authorizationUrl), + catch: () => new CodexAuthCallbackError({ detail: "Invalid ChatGPT sign-in request." }), + }), +}); diff --git a/apps/desktop/src/ipc/methods/snapShot.test.ts b/apps/desktop/src/ipc/methods/snapShot.test.ts index 7e20890ef554..138bcbc36d30 100644 --- a/apps/desktop/src/ipc/methods/snapShot.test.ts +++ b/apps/desktop/src/ipc/methods/snapShot.test.ts @@ -51,7 +51,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { previewConfig: () => @@ -87,7 +87,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { state: Effect.succeed({ @@ -120,7 +120,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { state: Effect.succeed({ linuxBackend: "niri" }), @@ -156,13 +156,11 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed( - Option.some({ - getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), - getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), - webContents, - }), - ), + main: Effect.succeedSome({ + getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), + getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), + webContents, + }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -220,7 +218,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents })), + main: Effect.succeedSome({ webContents }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -256,7 +254,7 @@ describe("window capture IPC", () => { Effect.provideService( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Effect.provideService(DesktopSnapShot.DesktopSnapShot, null as never), @@ -277,7 +275,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { setup: (action: string) => @@ -295,7 +293,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -320,7 +318,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index cfb993d35cfa..e9535300104d 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -137,13 +137,11 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ }) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; return yield* sshEnvironment.ensureEnvironment(target, options).pipe( - Effect.catch((error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed({ - type: DesktopSshPasswordPromptCancelledType, - message: error.message, - }) - : Effect.fail(error), + Effect.catchIf(DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation, (error) => + Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }), ), ); }), diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 2618aa8eadb2..a46d1316b325 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -61,7 +61,7 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { label: Effect.succeed("WSL (default distro)"), start: Effect.void, stop: () => Effect.void, - currentConfig: Effect.succeed(Option.some(readyWslConfig)), + currentConfig: Effect.succeedSome(readyWslConfig), snapshot: Effect.succeed({ desiredRunning: true, ready: true, @@ -101,7 +101,7 @@ describe("getLocalEnvironmentBootstraps", () => { }; const retryingInstance: DesktopBackendManager.DesktopBackendInstance = { ...defaultWslInstance, - currentConfig: Effect.succeed(Option.some(retryingConfig)), + currentConfig: Effect.succeedSome(retryingConfig), snapshot: Effect.succeed({ desiredRunning: true, ready: false, @@ -128,16 +128,14 @@ describe("getLocalEnvironmentBootstraps", () => { it.effect("omits a bounded transient bootstrap after retries stop", () => { const stoppedInstance: DesktopBackendManager.DesktopBackendInstance = { ...defaultWslInstance, - currentConfig: Effect.succeed( - Option.some({ - ...readyWslConfig, - preflightFailure: Option.some({ - reason: "WSL probe timed out", - fatal: false, - retryLimit: 12, - }), + currentConfig: Effect.succeedSome({ + ...readyWslConfig, + preflightFailure: Option.some({ + reason: "WSL probe timed out", + fatal: false, + retryLimit: 12, }), - ), + }), snapshot: Effect.succeed({ desiredRunning: false, ready: false, @@ -163,7 +161,7 @@ describe("getWindowFullscreenState", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), }), ), ); @@ -206,7 +204,7 @@ describe("pasteAsText", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeed(Option.some(window)), + main: Effect.succeedSome(window), }), ), ); @@ -219,7 +217,7 @@ describe("pickProjectFavicon", () => { Layer.mergeAll( Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeedNone, }), DesktopAppSettings.layerTest(settings), ); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index bfd1a6e679d9..65137cbbd895 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -2,7 +2,6 @@ import { DesktopWslStateSchema } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; @@ -34,7 +33,7 @@ function makeWslBackendLayer(input: { readonly onReconcile?: Effect.Effect DesktopWslBackend.DesktopWslBackend, DesktopWslBackend.DesktopWslBackend.of({ reconcile: input.onReconcile ?? Effect.void, - lastPreflightError: Effect.succeed(Option.none()), + lastPreflightError: Effect.succeedNone, }), ); } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..35e15e700ff4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -205,6 +205,7 @@ const desktopApplicationLayer = Layer.mergeAll( ); const desktopClerkLayer = DesktopClerk.layer.pipe( + Layer.provideMerge(ElectronShell.layer), Layer.provideMerge(desktopEnvironmentLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ElectronApp.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 85b3f0f8f52c..1202d7950911 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -79,6 +79,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + onTrackpadScrollEnd: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; @@ -181,6 +186,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { items, ...(position === undefined ? {} : { position }), }), + receiveProviderAuthCallback: (url: string) => + ipcRenderer.invoke(IpcChannels.RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL, url), + cancelProviderAuthCallback: (url: string) => + ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), checkSystemPermission: (pane: string) => ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane), diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts index cc7aac6cfd30..0420e755d92b 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts @@ -7,10 +7,6 @@ vi.mock("@napi-rs/keyring", () => { throw new Error("Cannot find native binding"); }); -it("loads browser import code without a keyring native binding", async () => { - await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined(); -}); - it.effect("reports an unavailable keychain when the macOS binding cannot load", () => Effect.gen(function* () { const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 9310fd1c92f7..9c03523191a3 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -315,8 +315,9 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") // 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. - Effect.catch((error) => - error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error), + Effect.catchIf( + (error) => error.reason !== "needsKeychainApproval", + (error) => Effect.succeed(error), ), ) : undefined; diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 96d27129b78a..0db4fc9096ec 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -114,56 +114,55 @@ const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefi return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; }; -export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( - cookieDatabasePath: string, -) { - const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( - Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), - ); +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")( + function* (cookieDatabasePath: string) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath); - const { rows, schemaVersion } = yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); - const schemaVersion = versionRow?.user_version ?? 0; - const hasRawSameSite = - schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && - schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Only the default container. 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. - const raw = hasRawSameSite - ? yield* sql` + const { rows, schemaVersion } = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); + const schemaVersion = versionRow?.user_version ?? 0; + const hasRawSameSite = + schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && + schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; + // Only the default container. 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. + const raw = hasRawSameSite + ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite from moz_cookies where originAttributes = '' ` - : yield* sql` + : yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, null as rawSameSite from moz_cookies where originAttributes = '' `; - return { rows: yield* decodeCookieRows(raw), schemaVersion }; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), - Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), - ); + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); - return rows.map((row) => { - const secure = row.isSecure === 1; - const scope = cookieScope(row.host, row.path, secure); - return { - url: scope.url, - name: row.name, - value: row.value, - domain: scope.domain, - path: row.path, - secure, - httpOnly: row.isHttpOnly === 1, - expirationDate: expiryToSeconds(row.expiry, schemaVersion), - sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), - } satisfies ImportedCookie; - }); -}); + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: expiryToSeconds(row.expiry, schemaVersion), + sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), + } satisfies ImportedCookie; + }); + }, + (effect, cookieDatabasePath) => + effect.pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ), +); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 930c13990d51..664302d7f83b 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -231,8 +231,9 @@ export const make = Effect.gen(function* BrowserSessionMake() { getSession, clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); - yield* Effect.all( - selectSessions(sessions, partitions).map(([partition, browserSession]) => + yield* Effect.forEach( + selectSessions(sessions, partitions), + ([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ @@ -244,14 +245,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { cause, }), }), - ), { concurrency: "unbounded", discard: true }, ); }), clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); - yield* Effect.all( - selectSessions(sessions, partitions).map(([partition, browserSession]) => + yield* Effect.forEach( + selectSessions(sessions, partitions), + ([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), catch: (cause) => @@ -260,7 +261,6 @@ export const make = Effect.gen(function* BrowserSessionMake() { cause, }), }), - ), { concurrency: "unbounded", discard: true }, ); }), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 468c47065315..b97af6f05204 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -942,14 +942,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function delivery: () => Effect.Effect, ) => Effect.suspend(delivery).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("Desktop preview event listener failed.", { - eventKind, - tabId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), ), ); @@ -1115,15 +1115,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } return resolvedPath; }).pipe( - Effect.flatMap((resolvedPath) => - resolvedPath === null - ? Effect.fail( - new PreviewArtifactPathOutsideDirectoryError({ - artifactPath, - artifactDirectory: resolvedArtifactDirectory, - }), - ) - : Effect.succeed(resolvedPath), + Effect.filterOrFail( + (resolvedPath) => resolvedPath !== null, + () => + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), ), ); @@ -1404,14 +1402,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( - (method) => - attemptPromise( - { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wcDebugger.sendCommand(method), - ), - ), + yield* Effect.forEach( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wcDebugger.sendCommand(method), + ), { concurrency: "unbounded", discard: true }, ); return [ @@ -4056,13 +4053,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function receiptKey: JSON.stringify(`__t3NativeKey_${NodeCrypto.randomUUID()}`), })), ({ frames, receiptKey }) => - Effect.all( - frames.map((frame) => + Effect.forEach( + frames, + (frame) => evaluate(frame, `globalThis[${receiptKey}]?.dispose()`).pipe( Effect.timeoutOption(1_000), Effect.ignore, ), - ), { concurrency: "unbounded", discard: true }, ), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index e199f2b2f32e..cd7fea6ca116 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -75,7 +75,7 @@ const readClientSettings = ( settingsPath: string, ): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" @@ -98,7 +98,7 @@ const readClientSettings = ( onNone: () => Effect.succeed(Option.none()), onSome: (raw) => decodeClientSettingsJson(raw).pipe( - Effect.map((settings) => Option.some(settings)), + Effect.asSome, Effect.catchTags({ SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index dad53815f3a6..b1e467869e8e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -94,7 +94,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, - selectedStorageBackend: Effect.succeed(Option.none()), + selectedStorageBackend: Effect.succeedNone, } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index d7634f980e25..7ee455756930 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -820,7 +820,7 @@ export const make = Effect.gen(function* () { }; const emit = (event: DesktopSnapShotEvent) => - desktopWindow.dispatchSnapShotEvent(event).pipe(Effect.catchCause(() => Effect.void)); + desktopWindow.dispatchSnapShotEvent(event).pipe(Effect.ignoreCause); const setFailure = (message: string, captureId?: string) => Ref.update(stateRef, (state) => ({ ...state, message })).pipe( Effect.andThen( @@ -846,10 +846,9 @@ export const make = Effect.gen(function* () { const discardCapture = Effect.fn("desktop.snapShot.discardCapture")(function* (id: string) { closeLinuxFeedback(id); transition.dismiss(id); - yield* Effect.all( - [`${id}.png`, `${id}.tmp.png`, `${id}.json`, `${id}.json.tmp`].map((name) => - fileSystem.remove(path.join(captureDirectory, name), { force: true }), - ), + yield* Effect.forEach( + [`${id}.png`, `${id}.tmp.png`, `${id}.json`, `${id}.json.tmp`], + (name) => fileSystem.remove(path.join(captureDirectory, name), { force: true }), { concurrency: "unbounded", discard: true }, ).pipe(Effect.ignore); }); @@ -904,7 +903,7 @@ export const make = Effect.gen(function* () { if (snapshot.animationStarted) { yield* emit({ type: "started", id: id as DesktopSnapShotId }); } else { - yield* desktopWindow.activate.pipe(Effect.catchCause(() => Effect.void)); + yield* desktopWindow.activate.pipe(Effect.ignoreCause); } return { id, capturedAt, ...snapshot }; }).pipe(Effect.mapError((cause) => captureFailure(cause, id))); @@ -1511,7 +1510,7 @@ export const make = Effect.gen(function* () { null, ), ), - Effect.catch(() => Effect.void), + Effect.ignore, ), ), configure, diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index 1c638f2444de..ad7b2e03c495 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -11,7 +11,11 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record["wind ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + main: Effect.succeedSome(window as Electron.BrowserWindow), + currentMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow), + focusedMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow), setMain: () => Effect.void, clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 18ea0a8380a3..a191a0d586ae 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -289,12 +289,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { yield* Ref.set(latest, Option.some(snapshot)); yield* PubSub.publish(changes, snapshot); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("Failed to sample Electron telemetry", { - cause: String(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("Failed to sample Electron telemetry", { + cause: String(cause), + }), ), ); @@ -306,6 +306,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { Ref.get(diagnosticsDemandSources).pipe(Effect.map((sources) => sources.size > 0)), Ref.get(hostPowerIntervals), ]); + // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races a trigger queue against the interval; both arms are real outcomes, not a timeout const allowSuspendRecovery = yield* Effect.raceFirst( Queue.take(sampleTriggers).pipe(Effect.as(false)), Effect.sleep(sampleInterval(currentPower, demand, intervals)).pipe(Effect.as(true)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ea1092bb23be..3664ea171c94 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -14,6 +15,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; @@ -85,6 +87,25 @@ describe("DesktopUpdates", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => + Effect.gen(function* () { + const linuxState = (packageType: string | undefined) => + Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + return yield* updates.getState; + }), + ).pipe(Effect.provide(makeHarness({ platform: "linux", packageType }).layer)); + + const deb = yield* linuxState("deb\n"); + assert.equal(deb.status, "idle"); + + const unmarked = yield* linuxState(undefined); + assert.equal(unmarked.status, "disabled"); + }), + ); + it.effect("subscribe delivers the latest state plus subsequent changes", () => { const harness = makeHarness(); @@ -560,6 +581,55 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("marks the backend stop for an install as an update restart", () => { + let markersAtStop: ReadonlyArray = []; + const harness = makeHarness({ + stopBackend: Effect.sync(() => { + markersAtStop = [...harness.updateRestartMarkers]; + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + assert.isTrue((yield* updates.install).accepted); + assert.deepEqual(markersAtStop, [ + environment.path.join(environment.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE), + ]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("drops the update restart marker when an install is interrupted", () => + Effect.gen(function* () { + const stopping = yield* Deferred.make(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(stopping, undefined).pipe(Effect.andThen(Effect.never)), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const installFiber = yield* updates.install.pipe(Effect.forkScoped); + yield* Deferred.await(stopping); + assert.equal(harness.updateRestartMarkers.size, 1); + + yield* Fiber.interrupt(installFiber); + assert.equal(harness.updateRestartMarkers.size, 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { const harness = makeHarness({ quitAndInstall: Effect.fail( @@ -584,6 +654,8 @@ describe("DesktopUpdates", () => { assert.isTrue(result.accepted); assert.isFalse(yield* Ref.get(desktopState.quitting)); assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + // The restarted old backend must release its tunnel on a later quit. + assert.equal(harness.updateRestartMarkers.size, 0); }), ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c35b52e8343d..245925a6e731 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,4 +1,5 @@ import { + DESKTOP_UPDATE_RESTART_MARKER_FILE, DesktopUpdateChannelSchema, type DesktopRuntimeInfo, type DesktopUpdateActionResult, @@ -249,6 +250,7 @@ function getAutoUpdateDisabledReason(args: { isPackaged: boolean; platform: NodeJS.Platform; appImage?: string | undefined; + isDebPackage: boolean; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -261,8 +263,8 @@ function getAutoUpdateDisabledReason(args: { if (args.disabledByEnv) { return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; + if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { + return "Automatic updates on Linux require the AppImage or the .deb package."; } return null; } @@ -331,6 +333,18 @@ 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. + const isDebPackage = + environment.platform === "linux" && environment.isPackaged + ? yield* fileSystem + .readFileString(environment.path.join(environment.resourcesPath, "package-type")) + .pipe( + Effect.map((packageType) => packageType.trim() === "deb"), + Effect.orElseSucceed(() => false), + ) + : false; + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), ); @@ -343,6 +357,7 @@ export const make = Effect.gen(function* () { isPackaged: environment.isPackaged, platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), + isDebPackage, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), @@ -501,8 +516,35 @@ export const make = Effect.gen(function* () { ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + // Tells the primary backend that the coming stop is an update restart, so it + // keeps its managed tunnel for the backend the updated app starts. Best + // effort: without the marker the backend only re-provisions its tunnel. + const updateRestartMarkerDir = environment.path.join(environment.baseDir, "runtime"); + const updateRestartMarkerPath = environment.path.join( + updateRestartMarkerDir, + DESKTOP_UPDATE_RESTART_MARKER_FILE, + ); + const writeUpdateRestartMarker = fileSystem + .makeDirectory(updateRestartMarkerDir, { recursive: true }) + .pipe( + Effect.andThen(fileSystem.writeFileString(updateRestartMarkerPath, "")), + Effect.catch((error) => + logUpdaterWarning("Could not write the update restart marker.", { errorTag: error._tag }), + ), + ); + + // A failed or interrupted install brings no updated backend, so a later + // quit must release the tunnel. + const removeUpdateRestartMarker = fileSystem + .remove(updateRestartMarkerPath, { force: true }) + .pipe(Effect.ignore); + const resetInstallAction = Effect.all( - [finishUpdateAction("install"), Ref.set(desktopState.quitting, false)], + [ + finishUpdateAction("install"), + Ref.set(desktopState.quitting, false), + removeUpdateRestartMarker, + ], { discard: true }, ); @@ -517,6 +559,7 @@ export const make = Effect.gen(function* () { if (!ownsRecovery) return; yield* Ref.set(desktopState.quitting, false); + yield* removeUpdateRestartMarker; yield* Effect.gen(function* () { const instances = yield* pool.list; const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { @@ -586,6 +629,7 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, true); return yield* Effect.gen(function* () { + yield* writeUpdateRestartMarker; // 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 @@ -638,24 +682,25 @@ export const make = Effect.gen(function* () { }), ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); - const installWithExpectedVersion = (expectedVersion?: string) => - Effect.gen(function* () { - if (yield* Ref.get(desktopState.quitting)) { - return { - accepted: false, - completed: false, - failed: false, - state: yield* Ref.get(updateStateRef), - }; - } - const result = yield* installDownloadedUpdate(expectedVersion); + const installWithExpectedVersion = Effect.fn("desktop.updates.install")(function* ( + expectedVersion?: string, + ) { + if (yield* Ref.get(desktopState.quitting)) { return { - accepted: result.accepted, - completed: result.completed, - failed: result.failed, + accepted: false, + completed: false, + failed: false, state: yield* Ref.get(updateStateRef), }; - }).pipe(Effect.withSpan("desktop.updates.install")); + } + const result = yield* installDownloadedUpdate(expectedVersion); + return { + accepted: result.accepted, + completed: result.completed, + failed: result.failed, + state: yield* Ref.get(updateStateRef), + }; + }); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index fbcbb349f9e7..5455f1602855 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -32,6 +34,9 @@ export interface UpdatesHarnessOptions { readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; readonly env?: Record; + readonly platform?: NodeJS.Platform; + /** Contents of the resources/package-type marker a Linux package ships. */ + readonly packageType?: string | undefined; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -106,9 +111,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), + main: Effect.succeedNone, + currentMainOrFirst: Effect.succeedNone, + focusedMainOrFirst: Effect.succeedNone, setMain: () => Effect.void, clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), @@ -130,7 +135,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps.push("startBackend"); }).pipe(Effect.andThen(options.startBackend ?? Effect.void)), stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), + currentConfig: Effect.succeedNone, snapshot: Effect.succeed({ desiredRunning: false, ready: false, @@ -145,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", + platform: options.platform ?? "darwin", processArch: "x64", appVersion: "1.2.3", appPath: "/repo", @@ -203,7 +208,34 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) : DesktopAppSettings.layer; + // Tracks the restart markers installs leave, so installs stay free of real + // disk I/O that would outrun the tests' settle loops. + const updateRestartMarkers = new Set(); + const fileSystemLayer = FileSystem.layerNoop({ + readFileString: (path) => + path === "/missing/resources/package-type" && options.packageType !== undefined + ? Effect.succeed(options.packageType) + : Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "readFileString", + _tag: "NotFound", + pathOrDescriptor: path, + }), + ), + makeDirectory: () => Effect.void, + writeFileString: (path) => + Effect.sync(() => { + updateRestartMarkers.add(path); + }), + remove: (path) => + Effect.sync(() => { + updateRestartMarkers.delete(path); + }), + }); + const layer = DesktopUpdates.layer.pipe( + Layer.provide(fileSystemLayer), Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), @@ -226,8 +258,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount: () => checkCount, quitAndInstalls: () => quitAndInstallCount, installSteps, + updateRestartMarkers, downloadCount: () => downloadCount, - feedUrls: () => feedUrls, + feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 3e107d971866..70e879b75075 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import type * as Electron from "electron"; @@ -51,7 +50,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { } satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { - pickFolder: () => Effect.succeed(Option.none()), + pickFolder: () => Effect.succeedNone, pickFiles: () => Effect.succeed([]), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, @@ -63,7 +62,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { isInstallActive: Effect.succeed(false), subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, - disabledReason: Effect.succeed(Option.none()), + disabledReason: Effect.succeedNone, configure: Effect.void, setChannel: () => Effect.die("unexpected setChannel"), check: () => Effect.die("unexpected check"), @@ -98,7 +97,7 @@ const makeElectronMenuLayer = ( setApplicationMenu: (template) => Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, } satisfies ElectronMenu.ElectronMenu["Service"]); const configureMenu = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 78834a55690e..5df400b90c44 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -49,6 +49,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; @@ -150,7 +151,7 @@ function makeFakeBrowserWindow() { } const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ - get: Effect.succeed(Option.none()), + get: Effect.succeedNone, }); const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ @@ -184,7 +185,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, popupTemplate: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, } satisfies ElectronMenu.ElectronMenu["Service"]); const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { @@ -293,7 +294,7 @@ function makeTestLayer(input: { electronAppLayer, Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, popupTemplate: input.onPopupTemplate ?? (() => Effect.void), }), Layer.succeed(ElectronShell.ElectronShell, { @@ -709,6 +710,30 @@ describe("DesktopWindow", () => { }), ); + it.effect("forwards native trackpad release to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const send = vi.spyOn(fakeWindow.window.webContents, "send"); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + const onInput = fakeWindow.webContentsListeners.get("input-event"); + if (!onInput) return yield* Effect.die("input-event listener was not registered"); + onInput({}, { type: "gestureScrollUpdate" }); + assert.notInclude( + send.mock.calls.map(([channel]) => channel), + TRACKPAD_SCROLL_END_CHANNEL, + ); + onInput({}, { type: "gestureScrollEnd" }); + assert.isTrue(send.mock.calls.some(([channel]) => channel === TRACKPAD_SCROLL_END_CHANNEL)); + }).pipe(Effect.provide(layer)); + }), + ); + // Chromium hands the main window's zoom level down to embedded preview // guests, so every app zoom has to put the preview browser back at its own // zoom or zooming the UI drags the previewed page with it. diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index b3964cb929c7..754de3caa727 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -22,6 +22,7 @@ import { MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -661,6 +662,9 @@ export const make = Effect.gen(function* () { event.preventDefault(); } }); + window.webContents.on("input-event", (_event, input) => { + if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + }); window.on("page-title-updated", (event) => { event.preventDefault(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 3f20e58aa680..72bad30c06cc 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -138,7 +138,7 @@ export const layer = Layer.effect( const primaryConfig = yield* serverExposure.backendConfig; const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( Effect.provideService(NetService.NetService, net), - Effect.map((value) => Option.some(value)), + Effect.asSome, Effect.catch((error) => logWslBackendWarning("could not allocate port for WSL backend", { error: error.message, @@ -171,7 +171,7 @@ export const layer = Layer.effect( onReady: () => Ref.set(preflightErrorRef, Option.none()), }) .pipe( - Effect.map((registered) => Option.some(registered)), + Effect.asSome, Effect.catch((error) => logWslBackendWarning("WSL backend already registered, skipping start", { id: targetId, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 6907d8d6db4e..cca5a61cc566 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1040,11 +1040,7 @@ const preWarmImpl = ( const handle = yield* spawner.spawn(command); yield* handle.exitCode; }), - ).pipe( - Effect.timeoutOption(PRE_WARM_TIMEOUT), - Effect.asVoid, - Effect.catch(() => Effect.void), - ); + ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore); const windowsToWslPathImpl = ( distro: string | null, @@ -1272,15 +1268,14 @@ export const layer = Layer.effect( // distro. Negative results aren't cached so a transient wsl.exe failure // doesn't permanently disable tilde expansion. const userHomeCache = new Map(); - const getUserHome = (distro: string | null) => - Effect.gen(function* () { - const key = distro ?? "__default__"; - const cached = userHomeCache.get(key); - if (cached !== undefined) return Option.some(cached); - const resolved = yield* provideSpawner(getUserHomeImpl(distro)); - if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); - return resolved; - }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); + const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) { + const key = distro ?? "__default__"; + const cached = userHomeCache.get(key); + if (cached !== undefined) return Option.some(cached); + const resolved = yield* provideSpawner(getUserHomeImpl(distro)); + if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); + return resolved; + }); const getDistroIp = (distro: string | null) => provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index c451a89b5767..17ce805ffe16 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -85,6 +85,19 @@ export default defineConfig({ onlyBundle: false, }, }, + { + // boot.cjs requires the other two at runtime, so all three stay separate files. + format: "cjs", + outDir: "dist-electron", + dts: false, + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/boot.ts", "src/compileCache.ts"], + clean: false, + deps: { + neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", + }, + }, { format: "cjs", outDir: "dist-electron", diff --git a/apps/marketing/public/harnesses/antigravity.png b/apps/marketing/public/harnesses/antigravity.png deleted file mode 100644 index df1e22dbbd21..000000000000 Binary files a/apps/marketing/public/harnesses/antigravity.png and /dev/null differ diff --git a/apps/marketing/public/harnesses/antigravity.svg b/apps/marketing/public/harnesses/antigravity.svg new file mode 100644 index 000000000000..13e1ec9e9849 --- /dev/null +++ b/apps/marketing/public/harnesses/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/apps/marketing/public/harnesses/openai_dark.svg b/apps/marketing/public/harnesses/openai_dark.svg index b78a51db7bc6..956f87c99f60 100644 --- a/apps/marketing/public/harnesses/openai_dark.svg +++ b/apps/marketing/public/harnesses/openai_dark.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/apps/marketing/public/harnesses/opencode-dark.svg b/apps/marketing/public/harnesses/opencode-dark.svg index fc467bf84407..8c5e734ece6c 100644 --- a/apps/marketing/public/harnesses/opencode-dark.svg +++ b/apps/marketing/public/harnesses/opencode-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index c8391953203d..dca3e9528689 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -91,6 +91,16 @@ const imageProps = {

Linux

+

+ On ARM? Download the arm64 .deb or + AppImage. +

diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 2da4319b4c28..7ba29ba30d80 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -38,7 +38,7 @@ const mobileEndorsementRows = [
-
+
Antigravity
Google sign-in
@@ -710,11 +710,6 @@ const mobileEndorsementRows = [ object-fit: contain; } - /* The Antigravity icon ships with its own rounded dark tile, so it fills the - card edge to edge instead of sitting inside it. */ - .hf-antigravity .hero-float-card { background: #0d0d10; border-color: rgba(255, 255, 255, 0.1); } - .hf-antigravity .hero-float-card img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; } - @keyframes mark-in { from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); } @@ -830,7 +825,7 @@ const mobileEndorsementRows = [ flex-shrink: 0; width: 28px; height: 28px; display: grid; place-items: center; } - .harness-mark img { width: 22px; height: 22px; object-fit: contain; border-radius: 5px; } + .harness-mark img { width: 22px; height: 22px; object-fit: contain; } .harness-meta { flex: 1; min-width: 0; } .harness-name { diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e3b05d805765..76d48ffbff5f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -217,7 +217,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.3.0", + version: "1.3.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/assets/antigravity.png b/apps/mobile/assets/antigravity.png index df1e22dbbd21..ecf863511c66 100644 Binary files a/apps/mobile/assets/antigravity.png and b/apps/mobile/assets/antigravity.png differ diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz deleted file mode 100644 index 8fafc3896813..000000000000 Binary files a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz and /dev/null differ diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index 35086bbd75f9..00af98ac4b28 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -138,17 +138,7 @@ object AgentNotifications { // Match iOS foreground presentation. Consume suppressed alerts as well, // so a delivery retry cannot surface them after the app backgrounds. if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - val title = data["alert_title"].orEmpty().take(120) - // Grouped alerts list up to five 120-character thread titles. - val body = data["alert_body"].orEmpty().take(608) - val id = alertId.hashCode() - val notification = base(context, ALERT_CHANNEL) - .setContentTitle(title).setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) - .build() - manager(context).notify(ALERT_TAG, id, notification) + postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( "seenAlertsOrdered", @@ -157,6 +147,36 @@ object AgentNotifications { } } + private fun postAlert( + context: Context, + scheme: String, + data: Map, + alertId: String + ) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + } + + /** + * Renders a relay-shaped payload without the registration, freshness and + * foreground checks, for the showcase capture's staged notifications. + */ + @Synchronized + fun showcase(context: Context, scheme: String, data: Map) { + channels(context) + data["alert_id"]?.let { postAlert(context, scheme, data, it) } + showActivity(context, scheme, data, data["active"] == "true", RUNNING_LIFETIME_MS) + } + private fun updateActivity( context: Context, prefs: SharedPreferences, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index 246db274a195..0a2d2b30589f 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -26,6 +26,10 @@ class T3AgentNotificationsModule : Module() { appContext.reactContext?.let { AgentNotifications.clear(it) } } + Function("showShowcaseActivity") { scheme: String, data: Map -> + appContext.reactContext?.let { AgentNotifications.showcase(it, scheme, data) } + } + Function("openLiveUpdateSettings") { val context = appContext.reactContext if (context == null || Build.VERSION.SDK_INT < 36) { diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml index e66f03cffae5..e98c1ad6b312 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ - + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt index ef33a5d7c25d..b8a916129ea7 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -8,7 +8,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Bundle +import android.os.Build import android.view.View import android.widget.RemoteViews import org.json.JSONObject @@ -29,15 +29,6 @@ class SubscriptionUsageWidget : AppWidgetProvider() { context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) } - override fun onAppWidgetOptionsChanged( - context: Context, - manager: AppWidgetManager, - id: Int, - options: Bundle - ) { - update(context, manager, id) - } - companion object { const val PREFERENCES = "t3_subscription_widget" private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" @@ -56,59 +47,68 @@ class SubscriptionUsageWidget : AppWidgetProvider() { } private fun update(context: Context, manager: AppWidgetManager, id: Int) { + // The receiver is disabled below 12L (values-v32/bools.xml), but the module still calls in. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S_V2) return val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() - val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) - openAppIntent(context, id, snapshot)?.let { - views.setOnClickPendingIntent(R.id.t3_widget_root, it) - } + val openApp = openAppIntent(context, id, snapshot) val providers = snapshot?.optJSONArray("providers") val now = System.currentTimeMillis() var nextExpiry = Long.MAX_VALUE - var totalRows = 0 val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> val provider = providers?.optJSONObject(index) ?: return@mapNotNull null val windows = provider.optJSONArray("windows") val expiresAt = provider.optLong("expiresAt") if (expiresAt > now && windows != null && windows.length() > 0) { nextExpiry = minOf(nextExpiry, expiresAt) - totalRows += provider.optInt("totalWindows", windows.length()) (0 until windows.length()).map { provider to windows.optJSONObject(it) } } else { - totalRows++ listOf(provider to null) } } - // Show each provider before filling spare space with its other windows. + // Keep the first quota from each provider near the top of the list. val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> groups.mapNotNull { it.getOrNull(index) } } - if (rows.isNotEmpty()) { - views.removeAllViews(R.id.t3_widget_rows) - val options = manager.getAppWidgetOptions(id) - val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) - val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size) - for ((provider, window) in rows.take(count)) { - views.addView(R.id.t3_widget_rows, rowView(context, provider, window)) - } - val remaining = totalRows - count - val checkedAt = snapshot?.optLong("checkedAt") ?: 0 + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + // Count limits only; "Open app to refresh" placeholders are not entries. + val limits = rows.count { (_, window) -> window != null } + // Without limits the layout's plain title stays. + if (limits > 0) { + views.setTextViewText( + R.id.t3_widget_title, + context.getString(R.string.t3_subscription_widget_title_count, limits) + ) + views.setContentDescription( + R.id.t3_widget_title, + context.resources.getQuantityString( + R.plurals.t3_subscription_widget_title_description, + limits, + limits + ) + ) + } + openApp?.let { views.setOnClickPendingIntent(R.id.t3_widget_root, it) } + openAppIntent(context, id, snapshot, forCollection = true)?.let { + views.setPendingIntentTemplate(R.id.t3_widget_rows, it) + } + val items = RemoteViews.RemoteCollectionItems.Builder() + rows.forEachIndexed { index, (provider, window) -> + items.addItem(index.toLong(), rowView(context, provider, window)) + } + views.setRemoteAdapter(R.id.t3_widget_rows, items.build()) + views.setEmptyView(R.id.t3_widget_rows, R.id.t3_widget_empty) + val checkedAt = snapshot?.optLong("checkedAt") ?: 0 + val checked = if (checkedAt > 0) { val formatted = DateFormat.getDateTimeInstance( DateFormat.SHORT, DateFormat.SHORT ).format(Date(checkedAt)) - val more = if (remaining > 0) { - context.getString(R.string.t3_subscription_widget_more, remaining) - } else { - "" - } - val checked = if (checkedAt > 0) { - context.getString(R.string.t3_subscription_widget_as_of, formatted) - } else { - context.getString(R.string.t3_subscription_widget_unknown_check) - } - views.setTextViewText(R.id.t3_widget_footer, checked + more) + context.getString(R.string.t3_subscription_widget_last_checked, formatted) + } else { + context.getString(R.string.t3_subscription_widget_unknown_check) } + views.setTextViewText(R.id.t3_widget_footer, checked) val alarms = context.getSystemService(AlarmManager::class.java) alarms.cancel(expiryIntent(context)) // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. @@ -118,7 +118,12 @@ class SubscriptionUsageWidget : AppWidgetProvider() { manager.updateAppWidget(id, views) } - private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { + private fun openAppIntent( + context: Context, + id: Int, + snapshot: JSONObject?, + forCollection: Boolean = false + ): PendingIntent? { // Target this variant's launcher so co-installed builds cannot steal the tap. val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null @@ -129,9 +134,14 @@ class SubscriptionUsageWidget : AppWidgetProvider() { intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP return PendingIntent.getActivity( context, - id, + id * 2 + if (forCollection) 1 else 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_UPDATE_CURRENT or if (forCollection) { + // Collection rows use fill-in intents with an explicit app target. + PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_IMMUTABLE + } ) } @@ -153,6 +163,7 @@ class SubscriptionUsageWidget : AppWidgetProvider() { val reset = window?.optString("reset") ?: context.getString(R.string.t3_subscription_widget_refresh) child.setTextViewText(R.id.t3_widget_reset, reset) + child.setOnClickFillInIntent(R.id.t3_widget_row, Intent()) child.setContentDescription( R.id.t3_widget_row, "$label. $windowLabel. $percent. $reset. $detail" diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml index 58e55bc2c81e..bf800d249fe0 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml @@ -1,9 +1,10 @@ - - - - + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml new file mode 100644 index 000000000000..dc1751bcc4f0 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-v32/bools.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml new file mode 100644 index 000000000000..8d74a0fd7d17 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/bools.xml @@ -0,0 +1,3 @@ + + false + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml index 7ef70aa44a46..586b82595345 100644 --- a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -1,11 +1,15 @@ Last checked unavailable Subscription usage + Subscription usage (%1$d) + + Subscription usage, %1$d entry + Subscription usage, %1$d entries + Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. - Open T3 Code and connect an environment to see limits. + No subscription limits available. Open T3 Code to connect. Tap to open Usage Open app to refresh %1$d%% remaining - As of %1$s - · +%1$d more + Last checked %1$s diff --git a/apps/mobile/package.json b/apps/mobile/package.json index dba137f30472..99d06e089e3a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,6 @@ "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", - "@expo/metro-runtime": "~57.0.14", "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", "@material/material-color-utilities": "0.3.0", @@ -137,9 +136,6 @@ "tailwindcss": "^4.0.0", "typescript": "catalog:" }, - "overrides": { - "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" - }, "expo": { "install": { "exclude": [ diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1537606b3df2..1487ea9c467e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,7 +10,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Platform, Pressable, @@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { + RenderErrorBoundary, + RenderFailureView, + type RenderFailureProps, +} from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; @@ -66,6 +71,7 @@ import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppea import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; +import { SettingsEnvironmentDetailRouteScreen } from "./features/settings/SettingsEnvironmentDetailRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsEnvironmentAgentBehaviorRouteScreen, @@ -189,6 +195,11 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Environments", }, }), + SettingsEnvironmentDetail: createNativeStackScreen({ + screen: SettingsEnvironmentDetailRouteScreen, + linking: "environments/:environmentId", + options: { title: "Environment" }, + }), SettingsEnvironmentNewThreads: createNativeStackScreen({ screen: SettingsEnvironmentNewThreadsRouteScreen, linking: "new-threads", @@ -285,6 +296,15 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Diagnostics", }, }), + // Deliberately the one settings screen with no `linking:` path. Its params + // are a tap-time snapshot, not a stable resource address: `now` is the + // wall-clock of the tap, `environmentIds` is the usage screen's local + // filter selection, and the window id/kind identify freshly aggregated + // pools. React Navigation round-trips non-path params through the URL as + // query strings, so a path here would bake in a permanently stale + // timestamp and filter. The deep linkable surface is the list at + // `settings/usage`, which rebuilds this state and pushes the detail from a + // tapped account segment. SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, options: { title: "Account" }, @@ -650,6 +670,14 @@ const RootStackConfig = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, options: SOLID_HEADER_OPTIONS, }), + // Deliberately the one root route with no `linking:` path. The route + // carries zero params: its content is a session object (staged model, + // provider groups, and live update callbacks) that the active + // ThreadComposer presents into ExistingThreadSettingsRouteProvider before + // pushing this screen — state no URL can reconstruct. Reached without a + // presented session the screen navigates straight back, so a path would + // only produce a flash-and-dismiss link. Deep links to a thread land on + // `threads/:environmentId/:threadId`, where this sheet is one tap away. ThreadSettingsSheet: createNativeStackScreen({ screen: ExistingThreadSettingsRouteScreen, options: { @@ -761,10 +789,13 @@ const RootStackConfig = createNativeStackNavigator({ // The whole new-task flow (choose project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. - layout: ({ children }) => ( - - {children} - + // A screen's layout replaces the navigator's screenLayout. + layout: ({ children, route }) => ( + + + {children} + + ), options: { gestureEnabled: true, @@ -778,6 +809,33 @@ const RootStackConfig = createNativeStackNavigator({ }, }); +function GuardedScreenLayout(props: { + readonly children: ReactNode; + readonly route: { readonly name: string; readonly params?: object | undefined }; +}) { + return ( + ( + + )} + > + {props.children} + + ); +} + +function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) { + const navigation = useNavigation(); + const exit = navigation.canGoBack() + ? { label: "Go back", onPress: () => navigation.goBack() } + : props.routeName === "Home" + ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") } + : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) }; + + return ; +} + export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); const usesWorkspaceFlowScreens = @@ -785,6 +843,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig return ( { if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { return {}; diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index dfa0dea8d05a..4c465a9f9ef4 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -7,9 +7,9 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { OverlayPortal } from "./OverlayPortal"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { MaterialMenuPopup } from "./MaterialMenuPopup"; -const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; const ANCHOR_GAP = 6; @@ -55,6 +55,7 @@ export type AndroidAnchoredMenuProps = { * menus use the native popup for placement, animation and dismissal. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing(); const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. @@ -68,6 +69,10 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { // are converted into this frame, so the menu lands correctly no matter // where the portal host sits (status bar, keyboard resize, etc.). const [overlay, setOverlay] = useState(null); + const menuWidth = + overlay === null + ? desiredMenuWidth + : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN)); const anchorRef = useRef(null); const overlayRef = useRef(null); @@ -131,14 +136,11 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { ? 0 : local.x + local.width / 2 <= overlay.width / 2 ? local.x - : local.x + local.width - MENU_WIDTH; + : local.x + local.width - menuWidth; const left = overlay === null ? 0 - : Math.min( - Math.max(preferredLeft, SCREEN_MARGIN), - overlay.width - MENU_WIDTH - SCREEN_MARGIN, - ); + : Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the // composer-pill menus "open down" into the IME and can't be tapped. @@ -201,6 +203,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( 2 ? (headerWidth >= 600 ? 3 : 1) : actions.length; @@ -51,16 +51,13 @@ export function AndroidScreenHeader(props: { return ( setHeaderWidth(event.nativeEvent.layout.width)} - className="border-b border-header-border bg-header px-2 pb-2" + className="border-b border-header-border bg-header px-2" style={{ - paddingTop: props.embedded ? 8 : Math.max(insets.top, 12), + ...headerPadding, borderBottomWidth: props.hideBottomBorder ? 0 : undefined, }} > - + {props.onBack ? ( - + Turbo - + {stageLabel} diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index 8ea70a45c020..b6b3d6e78a49 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { Pressable } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { SymbolView } from "./AppSymbol"; import { ControlPillMenu } from "./ControlPill"; @@ -15,6 +16,7 @@ export function ComposerAttachmentButton(props: { readonly onPickMedia: () => Promise; readonly onPickFiles: () => Promise; }) { + const { scale } = useAndroidControlSizing(); const button = ( ["name"]; - readonly iconNode?: ReactNode; + readonly renderIcon?: (size: number) => ReactNode; readonly label: string; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; @@ -39,6 +41,7 @@ export function ComposerInlineControl(props: { readonly chevronDirection?: "down" | "right"; readonly showChevron?: boolean; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); return ( - {props.iconNode ? ( - {props.iconNode} + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + ) : props.icon ? ( void; readonly variant?: "primary" | "danger"; }) { + const { scale, smallIconSize } = useAndroidControlSizing(); + const circleSize = Math.round(30 * scale); return ( void; + readonly onCancel?: () => void; +}; + +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index aa1653055b59..67e9d465c573 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -4,25 +4,9 @@ import { Platform, Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { MaterialConfirmDialog } from "./MaterialConfirmDialog"; +import type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; -export type ConfirmDialogRequest = { - readonly title: string; - readonly message?: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly destructive?: boolean; - readonly onConfirm: () => void; - readonly onCancel?: () => void; -}; - -export type TextInputDialogRequest = { - readonly title: string; - readonly initialValue: string; - readonly cancelText?: string; - readonly confirmText: string; - readonly onConfirm: (value: string) => void; - readonly onCancel?: () => void; -}; +export type { ConfirmDialogRequest, TextInputDialogRequest } from "./ConfirmDialog.types"; type DialogRequest = | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index d8217ab67df5..95a8feb10a23 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -5,6 +5,7 @@ import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { MaterialIconButton } from "./MaterialIconButton"; import { MaterialButton } from "./MaterialButton"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export { ControlPillMenu } from "./ControlPillMenu"; @@ -20,6 +21,7 @@ export function ControlPill(props: { readonly className?: string; }) { const variant = props.variant ?? "circle"; + const { smallIconSize } = useAndroidControlSizing(); const activatedOnPressInRef = useRef(false); const handlePressIn = () => { @@ -133,7 +135,7 @@ export function ControlPill(props: { ) : props.icon ? ( diff --git a/apps/mobile/src/components/FilePreview.ios.tsx b/apps/mobile/src/components/FilePreview.ios.tsx index e2bae6101b60..4f06319a6846 100644 --- a/apps/mobile/src/components/FilePreview.ios.tsx +++ b/apps/mobile/src/components/FilePreview.ios.tsx @@ -2,7 +2,7 @@ import { requireNativeModule } from "expo"; import { useEffect, useEffectEvent, useId } from "react"; import { Alert } from "react-native"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; const NativeControls = requireNativeModule<{ presentFile( diff --git a/apps/mobile/src/components/FilePreview.tsx b/apps/mobile/src/components/FilePreview.tsx index 337699e8a61e..85286dbda891 100644 --- a/apps/mobile/src/components/FilePreview.tsx +++ b/apps/mobile/src/components/FilePreview.tsx @@ -3,7 +3,7 @@ import { Alert, Modal, Pressable, View } from "react-native"; import ImageViewing from "react-native-image-viewing"; import { openAttachmentInViewer } from "../lib/attachmentDownload"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaImagePreview } from "./MediaImagePreview"; import { AppText as Text } from "./AppText"; diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index 77dd5600bc15..0692109cacde 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -1,30 +1,13 @@ import { useIsFocused } from "@react-navigation/native"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; -import type { MediaActionsSource } from "../lib/mediaActions"; import { useRefreshAssetUrl } from "../state/assets"; import { FilePreview } from "./FilePreview"; +import type { FilePreviewSource } from "./FilePreviewModal.types"; -export interface ResolvedFilePreviewSource { - readonly kind: "image" | "pdf" | "document"; - readonly mimeType?: string; - readonly uri: string; - readonly name?: string; - readonly sourceIdentifier?: string; - readonly srcFragment?: string; - readonly actionsSource?: MediaActionsSource; -} - -export type FilePreviewSource = Omit & - ( - | { readonly uri: string } - | { readonly attachment: FileBackedComposerAttachment } - | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } - ); +export type { FilePreviewSource, ResolvedFilePreviewSource } from "./FilePreviewModal.types"; function ResolvedFilePreview(props: { readonly source: FilePreviewSource; diff --git a/apps/mobile/src/components/FilePreviewModal.types.ts b/apps/mobile/src/components/FilePreviewModal.types.ts new file mode 100644 index 000000000000..6d125125c80f --- /dev/null +++ b/apps/mobile/src/components/FilePreviewModal.types.ts @@ -0,0 +1,21 @@ +import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; + +import type { FileBackedComposerAttachment } from "../lib/composerImages"; +import type { MediaActionsSource } from "../lib/mediaActions"; + +export interface ResolvedFilePreviewSource { + readonly kind: "image" | "pdf" | "document"; + readonly mimeType?: string; + readonly uri: string; + readonly name?: string; + readonly sourceIdentifier?: string; + readonly srcFragment?: string; + readonly actionsSource?: MediaActionsSource; +} + +export type FilePreviewSource = Omit & + ( + | { readonly uri: string } + | { readonly attachment: FileBackedComposerAttachment } + | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } + ); diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx index f2df6464f103..f7a62f1ecb0b 100644 --- a/apps/mobile/src/components/MaterialButton.android.tsx +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -14,10 +14,12 @@ import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import type { MaterialButtonProps } from "./MaterialButton"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialButton(props: MaterialButtonProps) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, mediumIconSize } = useAndroidControlSizing(); const tone = props.tone ?? "secondary"; const Component = tone === "text" ? TextButton : tone === "secondary" ? FilledTonalButton : Button; @@ -77,11 +79,11 @@ export function MaterialButton(props: MaterialButtonProps) { {props.loading ? ( <> - + ) : null} {props.label} diff --git a/apps/mobile/src/components/MaterialConfirmDialog.tsx b/apps/mobile/src/components/MaterialConfirmDialog.tsx index c21ad042c39c..7154c7d716d8 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.tsx @@ -1,4 +1,4 @@ -import type { ConfirmDialogRequest } from "./ConfirmDialogHost"; +import type { ConfirmDialogRequest } from "./ConfirmDialog.types"; export interface MaterialConfirmDialogProps { readonly request: Pick< diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx index 2882d0d91ec0..29f396fc9d6d 100644 --- a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -6,8 +6,9 @@ import { LargeFloatingActionButton, Text, } from "@expo/ui/jetpack-compose"; -import { size } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, height, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -24,6 +25,8 @@ export function MaterialFloatingActionButton(props: { }) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); + const { scale, iconSize: standardIconSize, fabSize, largeFabSize } = useAndroidControlSizing(); + const buttonSize = props.variant === "large" ? largeFabSize : fabSize; const primary = props.tone === "primary"; const containerColor = colors[primary ? "--color-primary" : "--color-secondary"]; const contentColor = @@ -34,7 +37,7 @@ export function MaterialFloatingActionButton(props: { : props.variant === "large" ? LargeFloatingActionButton : FloatingActionButton; - const iconSize = props.variant === "large" ? 36 : 24; + const iconSize = props.variant === "large" ? Math.round(36 * scale) : standardIconSize; return ( { if (!props.disabled) props.onPress?.(); }} - style={{ width: 48, height: 48 }} + style={{ width: buttonSize, height: buttonSize }} > diff --git a/apps/mobile/src/components/MaterialListRow.tsx b/apps/mobile/src/components/MaterialListRow.tsx index 539472e4bd96..cef104a02f50 100644 --- a/apps/mobile/src/components/MaterialListRow.tsx +++ b/apps/mobile/src/components/MaterialListRow.tsx @@ -5,6 +5,7 @@ import { useAppearancePreferences } from "../features/settings/appearance/Appear import { cn } from "../lib/cn"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; /** Shared geometry for Material navigation and selection lists. Group rows in one card. */ export function MaterialListRow({ @@ -23,6 +24,7 @@ export function MaterialListRow({ readonly trailing?: ReactNode; }) { const { themeVariables } = useAppearancePreferences(); + const { smallIconSize } = useAndroidControlSizing(); return ( + ) : null} ); diff --git a/apps/mobile/src/components/MaterialMenuPopup.android.tsx b/apps/mobile/src/components/MaterialMenuPopup.android.tsx index c49db1815039..4f9fe3d1ada4 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.android.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.android.tsx @@ -7,9 +7,11 @@ import { RNHostView, Text, } from "@expo/ui/jetpack-compose"; -import { padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; +import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers"; import { View } from "react-native"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import type { MaterialMenuPopupProps } from "./MaterialMenuPopup"; import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol"; @@ -19,12 +21,16 @@ function MenuIcon(props: { readonly destructive?: boolean; readonly disabled?: boolean; }) { + const { iconSize } = useAndroidControlSizing(); return ( - - + + {props.parent ? ( - + - + {props.parent.title} ) : props.title ? ( - + {props.title} ) : null} @@ -66,19 +81,22 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { props.onPress(action)} > + {action.image && isAppSymbolName(action.image) ? ( + + + + ) : null} {action.subtitle ? ( - + {action.subtitle} ) : null} - {action.image && isAppSymbolName(action.image) ? ( - - - - ) : null} {(action.subactions?.length ?? 0) > 0 ? ( @@ -124,7 +133,7 @@ export function MaterialMenuPopup(props: MaterialMenuPopupProps) { colorScheme={themeAppearance} ignoreSafeAreaKeyboardInsets matchContents - style={{ width: 250 }} + style={{ width: props.menuWidth }} > {items} diff --git a/apps/mobile/src/components/MaterialMenuPopup.tsx b/apps/mobile/src/components/MaterialMenuPopup.tsx index 3a23e5de7ab4..06019a0e1512 100644 --- a/apps/mobile/src/components/MaterialMenuPopup.tsx +++ b/apps/mobile/src/components/MaterialMenuPopup.tsx @@ -1,6 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; export interface MaterialMenuPopupProps { + readonly menuWidth: number; readonly anchor: { readonly x: number; readonly y: number; diff --git a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx index 018dbdc19b42..8c653e67ee9a 100644 --- a/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx +++ b/apps/mobile/src/components/MaterialScrollComposeButton.android.tsx @@ -1,9 +1,17 @@ import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; -import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers"; +import { + defaultMinSize, + fillMaxWidth, + graphicsLayer, + height, + onSizeChanged, + size, +} from "@expo/ui/jetpack-compose/modifiers"; import { useCallback, useState } from "react"; import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { resolveScaledTextRole } from "../lib/appearancePreferences"; /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ export function MaterialScrollComposeButton(props: { @@ -12,14 +20,23 @@ export function MaterialScrollComposeButton(props: { readonly className?: string; readonly style?: StyleProp; }) { - const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); - const typography = useScaledTextRole("footnote"); - const [expandedWidth, setExpandedWidth] = useState(56); + const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences(); + const typography = resolveScaledTextRole("footnote", appearance.baseFontSize); + const { iconSize, fabSize } = useAndroidControlSizing(); + // Scale the native 56dp minimum; keep text and icons at their requested sizes. + const nativeSize = Math.max(56, fabSize); + const scale = fabSize / nativeSize; + const nativeIconSize = Math.round(iconSize / scale); + const [buttonWidth, setButtonWidth] = useState(nativeSize); const rememberWidth = useCallback(({ width }: { width: number }) => { - setExpandedWidth((previous) => Math.max(previous, width)); + setButtonWidth((previous) => Math.max(previous, width)); }, []); return ( - + - + @@ -45,7 +72,11 @@ export function MaterialScrollComposeButton(props: { New thread @@ -65,8 +96,9 @@ export function MaterialScrollComposeButton(props: { right: 0, top: 0, bottom: 0, - width: props.expanded ? expandedWidth : 56, - borderRadius: 16, + // Release the label area as soon as collapse starts, before native measurements arrive. + width: props.expanded ? buttonWidth * scale : fabSize, + borderRadius: 16 * scale, overflow: "hidden", }} /> diff --git a/apps/mobile/src/components/MaterialSearchField.tsx b/apps/mobile/src/components/MaterialSearchField.tsx index ab96440f5f09..3609430a6e1a 100644 --- a/apps/mobile/src/components/MaterialSearchField.tsx +++ b/apps/mobile/src/components/MaterialSearchField.tsx @@ -2,6 +2,7 @@ import type { RefObject } from "react"; import { Pressable, TextInput, View } from "react-native"; import { SymbolView } from "./AppSymbol"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function MaterialSearchField({ inputRef, @@ -18,9 +19,21 @@ export function MaterialSearchField({ readonly value: string; readonly onChangeText: (value: string) => void; }) { + const { scale, mediumIconSize } = useAndroidControlSizing(); return ( - - + + @@ -49,7 +63,7 @@ export function MaterialSearchField({ > diff --git a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx index ceea91b24447..e941fc52eef1 100644 --- a/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedButtons.android.tsx @@ -2,7 +2,7 @@ import { SegmentedButton, SingleChoiceSegmentedButtonRow, Text } from "@expo/ui/ import { defaultMinSize, fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; /** Compose content shared by screen controls and native dialogs, inside their existing Host. */ export function MaterialSegmentedButtons( diff --git a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx index 16ec879821e1..76734e14d763 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.android.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.android.tsx @@ -3,7 +3,7 @@ import { MaterialSegmentedButtons } from "./MaterialSegmentedButtons.android"; import { View } from "react-native"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSegmentedControl.tsx b/apps/mobile/src/components/MaterialSegmentedControl.tsx index eca51d2d3f26..0935da6cd66c 100644 --- a/apps/mobile/src/components/MaterialSegmentedControl.tsx +++ b/apps/mobile/src/components/MaterialSegmentedControl.tsx @@ -1,4 +1,4 @@ -import type { SegmentedControlProps } from "./SegmentedControl"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; export function MaterialSegmentedControl( _props: SegmentedControlProps, diff --git a/apps/mobile/src/components/MaterialSwitch.android.tsx b/apps/mobile/src/components/MaterialSwitch.android.tsx index 2bc9d11130d7..f08fa78690f7 100644 --- a/apps/mobile/src/components/MaterialSwitch.android.tsx +++ b/apps/mobile/src/components/MaterialSwitch.android.tsx @@ -1,6 +1,6 @@ import { Host, Switch as ComposeSwitch } from "@expo/ui/jetpack-compose"; import { View } from "react-native"; -import type { ThemedSwitchProps } from "./ThemedSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; diff --git a/apps/mobile/src/components/MaterialSwitch.types.ts b/apps/mobile/src/components/MaterialSwitch.types.ts new file mode 100644 index 000000000000..657a2139a69e --- /dev/null +++ b/apps/mobile/src/components/MaterialSwitch.types.ts @@ -0,0 +1,12 @@ +import type { SwitchProps } from "react-native"; + +export type ThemedSwitchProps = Pick< + SwitchProps, + | "accessibilityHint" + | "accessibilityLabel" + | "disabled" + | "onValueChange" + | "style" + | "testID" + | "value" +>; diff --git a/apps/mobile/src/components/MediaImagePreview.tsx b/apps/mobile/src/components/MediaImagePreview.tsx index 03e317c296af..09bebcb28b66 100644 --- a/apps/mobile/src/components/MediaImagePreview.tsx +++ b/apps/mobile/src/components/MediaImagePreview.tsx @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useMediaActions } from "../lib/mediaActions"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; -import type { ResolvedFilePreviewSource } from "./FilePreviewModal"; +import type { ResolvedFilePreviewSource } from "./FilePreviewModal.types"; import { MediaActionsMenu } from "./MediaActionsMenu"; import { MediaSourceCaption } from "./MediaSourceCaption"; diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 932fc6779f20..83505730d808 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,8 +1,9 @@ import { SymbolView } from "./AppSymbol"; +import { AppText } from "./AppText"; import { Image } from "expo-image"; import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectIconOverride } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, @@ -11,6 +12,12 @@ import { import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { projectFaviconUrlAtom } from "../state/assets"; +import { + countGlyphs, + projectIconColorClassNames, + resolveProjectIconGlyph, + type ProjectIconGlyph, +} from "../lib/projectIcon"; import { beginProjectFaviconRequest, @@ -18,7 +25,7 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "../lib/projectFaviconRequests"; const EMPTY_FAVICON_URL = Atom.make(null); @@ -30,10 +37,12 @@ export function ProjectFavicon(props: { readonly projectTitle: string; readonly workspaceRoot?: string | null; readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; }) { const size = props.size ?? 42; + const glyph = resolveProjectIconGlyph(props.projectIcon, props.projectTitle); const faviconUrl = useAtomValue( - props.workspaceRoot == null + props.workspaceRoot == null || glyph !== null ? EMPTY_FAVICON_URL : projectFaviconUrlAtom({ environmentId: props.environmentId, @@ -51,6 +60,10 @@ export function ProjectFavicon(props: { : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; + if (glyph !== null) { + return ; + } + return ( + + {glyph.emoji} + + + ); + } + + const colors = projectIconColorClassNames(glyph.color); + return ( + + + {glyph.text} + + + ); +} + function ProjectFaviconImage(props: { readonly cacheKey: string | null; readonly faviconUrl: string | null; @@ -105,7 +168,7 @@ function ProjectFaviconImage(props: { {!showImage ? ( diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 374738d0aeca..49de96a8d74c 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -75,10 +75,12 @@ export function ProviderIcon(props: ProviderIconProps) { // codex (and unknown drivers) return ( - + ); diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx new file mode 100644 index 000000000000..9b71916d01e7 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,127 @@ +import { Component, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { MaterialButton } from "./MaterialButton"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +interface RenderErrorBoundaryProps { + readonly children: ReactNode; + readonly resetKeys?: ReadonlyArray; + readonly renderFallback?: (props: RenderFailureProps) => ReactNode; +} + +interface RenderErrorBoundaryState { + readonly failed: boolean; + readonly error: unknown; + readonly componentStack?: string; + readonly resetKeys?: ReadonlyArray; +} + +export interface RenderFailureProps { + readonly details: string; + readonly retry: () => void; +} + +function errorDetails(error: unknown, componentStack?: string): string { + let description: string; + try { + description = error instanceof Error ? (error.stack ?? error.message) : String(error); + } catch { + description = "Unknown render error"; + } + return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description; +} + +/** A failed subtree remounts on retry or when its identifying inputs change. */ +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { + failed: false, + error: null, + resetKeys: this.props.resetKeys, + }; + + static getDerivedStateFromProps( + { resetKeys }: RenderErrorBoundaryProps, + state: RenderErrorBoundaryState, + ): Partial | null { + if ( + resetKeys?.length !== state.resetKeys?.length || + resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) + ) { + return { failed: false, error: null, componentStack: undefined, resetKeys }; + } + return null; + } + + static getDerivedStateFromError(error: unknown): Partial { + return { failed: true, error }; + } + + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { + this.setState({ componentStack: info.componentStack }); + } + + private readonly retry = () => { + this.setState({ failed: false, error: null, componentStack: undefined }); + }; + + override render() { + if (!this.state.failed) return this.props.children; + const fallback = + this.props.renderFallback ?? + ((props: RenderFailureProps) => ); + return fallback({ + details: errorDetails(this.state.error, this.state.componentStack), + retry: this.retry, + }); + } +} + +export function RenderFailureView( + props: RenderFailureProps & { + readonly title?: string; + readonly bottomInset?: number; + readonly exit?: { readonly label: string; readonly onPress: () => void }; + }, +) { + const title = props.title ?? "This screen couldn't be displayed"; + return ( + + + {title} + + + Try again. If it keeps happening, copy the details for a bug report. + + + {props.details.split("\n", 1)[0]?.slice(0, 300)} + + + + copyTextWithHaptic(props.details, { target: "error details" })} + fullWidth + /> + {props.exit ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index 61a38c3a00c2..1d3232fd1641 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useMaterialToolbarLayout } from "./useMaterialToolbarLayout"; import { NativeStackScreenOptions } from "../native/StackHeader"; import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -11,10 +11,12 @@ import { ControlPillMenu } from "./ControlPill"; import { MaterialSearchField } from "./MaterialSearchField"; import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; import type { ScreenHeaderProps } from "./ScreenHeader.types"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; export function ScreenHeader(props: ScreenHeaderProps) { const { search } = props; - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); + const { scale, buttonSize, iconSize, smallIconSize } = useAndroidControlSizing(); const { themeVariables } = useAppearancePreferences(); const inputRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); @@ -48,11 +50,12 @@ export function ScreenHeader(props: ScreenHeaderProps) { @@ -80,30 +83,43 @@ export function ScreenHeader(props: ScreenHeaderProps) { <> {options} - + {props.onBack ? ( ) : null} - + @@ -112,9 +128,14 @@ export function ScreenHeader(props: ScreenHeaderProps) { autoCapitalize="none" onChangeText={search.onChangeText} value={search.value} - placeholder={search.placeholder} + placeholder={ + search.compactToolbar + ? (search.compactPlaceholder ?? search.placeholder) + : search.placeholder + } placeholderTextColorClassName="accent-placeholder" - className="flex-1 py-2 text-base font-sans text-header-foreground" + className="flex-1 text-base font-sans text-header-foreground" + style={{ paddingVertical: 7 * scale }} /> {menuView} @@ -173,11 +194,8 @@ export function ScreenHeader(props: ScreenHeaderProps) { {header} {searching ? ( - - + + { - readonly options: readonly { - readonly value: Value; - readonly label: string; - readonly accessibilityLabel?: string; - }[]; - readonly selected: Value; - readonly onSelect: (value: Value) => void; - /** Compact sizing applies to the non-Material control. */ - readonly size?: "default" | "compact"; - /** "tab" for the view switcher; filters stay plain buttons. */ - readonly role?: "tab" | "button"; - readonly className?: string; -} +export type { SegmentedControlProps } from "./SegmentedControl.types"; export function SegmentedControl( props: SegmentedControlProps, diff --git a/apps/mobile/src/components/SegmentedControl.types.ts b/apps/mobile/src/components/SegmentedControl.types.ts new file mode 100644 index 000000000000..921dcdb1cbde --- /dev/null +++ b/apps/mobile/src/components/SegmentedControl.types.ts @@ -0,0 +1,14 @@ +export interface SegmentedControlProps { + readonly options: readonly { + readonly value: Value; + readonly label: string; + readonly accessibilityLabel?: string; + }[]; + readonly selected: Value; + readonly onSelect: (value: Value) => void; + /** Compact sizing applies to the non-Material control. */ + readonly size?: "default" | "compact"; + /** "tab" for the view switcher; filters stay plain buttons. */ + readonly role?: "tab" | "button"; + readonly className?: string; +} diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx index f0cf8701e50c..d88ace54cb49 100644 --- a/apps/mobile/src/components/ThemedSwitch.tsx +++ b/apps/mobile/src/components/ThemedSwitch.tsx @@ -1,17 +1,9 @@ -import { Platform, Switch, type SwitchProps } from "react-native"; +import { Platform, Switch } from "react-native"; import { MaterialSwitch } from "./MaterialSwitch"; +import type { ThemedSwitchProps } from "./MaterialSwitch.types"; -export type ThemedSwitchProps = Pick< - SwitchProps, - | "accessibilityHint" - | "accessibilityLabel" - | "disabled" - | "onValueChange" - | "style" - | "testID" - | "value" ->; +export type { ThemedSwitchProps } from "./MaterialSwitch.types"; export function ThemedSwitch(props: ThemedSwitchProps) { if (Platform.OS === "android") { diff --git a/apps/mobile/src/components/useAndroidControlSizing.ts b/apps/mobile/src/components/useAndroidControlSizing.ts new file mode 100644 index 000000000000..e2e586bb07a6 --- /dev/null +++ b/apps/mobile/src/components/useAndroidControlSizing.ts @@ -0,0 +1,13 @@ +import { Platform } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { DEFAULT_BASE_FONT_SIZE } from "../lib/appearancePreferences"; +import { resolveAndroidControlSizing } from "../lib/androidControlSizing"; + +/** Shared layouts keep their existing dimensions on iOS. */ +export function useAndroidControlSizing() { + const { appearance } = useAppearancePreferences(); + return resolveAndroidControlSizing( + Platform.OS === "android" ? appearance.baseFontSize : DEFAULT_BASE_FONT_SIZE, + ); +} diff --git a/apps/mobile/src/components/useMaterialToolbarHeight.ts b/apps/mobile/src/components/useMaterialToolbarHeight.ts deleted file mode 100644 index 26e8510a82ff..000000000000 --- a/apps/mobile/src/components/useMaterialToolbarHeight.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useWindowDimensions } from "react-native"; - -import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; - -/** Reserve the same title/subtitle space in every pane, including icon-only and search headers. */ -export function useMaterialToolbarHeight() { - const title = useScaledTextRole("title"); - const subtitle = useScaledTextRole("label"); - const { fontScale } = useWindowDimensions(); - return Math.ceil(Math.max(56, (title.lineHeight + subtitle.lineHeight) * fontScale + 1)); -} diff --git a/apps/mobile/src/components/useMaterialToolbarLayout.ts b/apps/mobile/src/components/useMaterialToolbarLayout.ts new file mode 100644 index 000000000000..f68a6eec7a46 --- /dev/null +++ b/apps/mobile/src/components/useMaterialToolbarLayout.ts @@ -0,0 +1,21 @@ +import { useWindowDimensions } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; +import { useAndroidControlSizing } from "./useAndroidControlSizing"; + +/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ +export function useMaterialToolbarLayout(embedded = false) { + const insets = useSafeAreaInsets(); + const title = useScaledTextRole("title"); + const subtitle = useScaledTextRole("label"); + const { scale } = useAndroidControlSizing(); + const { fontScale } = useWindowDimensions(); + return { + height: Math.ceil( + Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + ), + paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), + paddingBottom: 7 * scale, + }; +} diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index c4a7cbdcce06..6e56524770b6 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -1,6 +1,7 @@ import { ConnectionPersistenceError, EnvironmentCacheStore, + encodeShellSnapshotForCache, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, @@ -15,7 +16,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; +import { + attachProjectFaviconDatabase, + projectFaviconDatabaseCache, +} from "../lib/projectFaviconDatabaseCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -53,7 +57,6 @@ const StoredVcsRefs = Schema.Struct({ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredShellSnapshot), ); -const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredThreadSnapshot), ); @@ -130,14 +133,21 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { - const payload = yield* encodeStoredShellSnapshot({ - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + const encodedSnapshot = yield* encodeShellSnapshotForCache(snapshot).pipe( + Effect.mapError((cause) => persistenceError("save-shell", cause)), + ); + const payload = yield* Effect.try({ + try: () => + JSON.stringify({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot: encodedSnapshot, + } satisfies typeof StoredShellSnapshot.Encoded), + catch: (cause) => persistenceError("save-shell", cause), + }); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); @@ -241,7 +251,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe( Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), ), diff --git a/apps/mobile/src/dependency-graph.test.ts b/apps/mobile/src/dependency-graph.test.ts new file mode 100644 index 000000000000..f4c953925bcc --- /dev/null +++ b/apps/mobile/src/dependency-graph.test.ts @@ -0,0 +1,288 @@ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +/** + * Dependency-graph guards for the mobile source tree (audit #13). + * + * 1. No circular imports, checked once per platform the way Metro resolves + * modules (`..*` before `.native.*` before the + * generic file). A cycle can hide behind platform resolution: a `.tsx` + * base importing a component whose `.android.tsx` variant type-imports + * back into the base is acyclic on iOS but circular on Android. + * Dynamic `import("...")` calls are excluded from this rule on purpose: + * they are the deliberate async escape hatch (e.g. composer-draft cleanup + * reaching `lib/attachmentUpload`), and Metro resolves them after both + * modules have initialized, so they cannot create an initialization cycle. + * + * 2. Cross-layer edges are ceilinged, not yet banned. `state`, `lib`, + * `native`, and `components` still reach upward into `features` at known + * sites (the app composition root `lib/runtime.ts` legitimately wires + * feature layers). Ceilings count unique `from -> to` module pairs across + * all platforms, including dynamic imports, and may only shrink: when you + * remove one of these imports, lower the constant in the same PR. + * + * Runs in CI as part of the `Test` job (`vp run --filter '!t3' test` picks up + * the `@t3tools/mobile` package test task). + */ + +const SOURCE_ROOT = __dirname; + +/** Metro candidate order per platform (platform, then native, then generic). */ +const PLATFORM_EXTENSION_ORDER = { + android: [".android.ts", ".android.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], + ios: [".ios.ts", ".ios.tsx", ".native.ts", ".native.tsx", ".ts", ".tsx"], +} as const; + +type Platform = keyof typeof PLATFORM_EXTENSION_ORDER; + +const PLATFORMS = Object.keys(PLATFORM_EXTENSION_ORDER) as ReadonlyArray; + +const isGraphFile = (filePath: string): boolean => + /\.tsx?$/.test(filePath) && + !filePath.includes(".test.") && + !filePath.includes("test-support") && + !filePath.endsWith(".d.ts"); + +/** Files Metro would not even bundle for the other platform. */ +function isRelevantForPlatform(filePath: string, platform: Platform): boolean { + const name = NodePath.basename(filePath); + if (platform === "android") { + return !name.includes(".ios."); + } + return !name.includes(".android."); +} + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of NodeFS.readdirSync(dir)) { + const filePath = NodePath.join(dir, entry); + if (NodeFS.statSync(filePath).isDirectory()) { + collectSourceFiles(filePath, out); + } else if (isGraphFile(filePath)) { + out.push(filePath); + } + } + return out; +} + +function resolveRelative( + fromFile: string, + specifier: string, + extensionOrder: ReadonlyArray, +): string | null { + if (!specifier.startsWith(".")) { + return null; + } + const base = NodePath.resolve(NodePath.dirname(fromFile), specifier); + for (const ext of extensionOrder) { + for (const candidate of [base + ext, NodePath.join(base, `index${ext}`)]) { + try { + if (NodeFS.statSync(candidate).isFile()) { + const resolved = NodePath.resolve(candidate); + return resolved.startsWith(SOURCE_ROOT + NodePath.sep) ? resolved : null; + } + } catch { + // Candidate does not exist; try the next one. + } + } + } + return null; +} + +interface ParsedImport { + readonly specifier: string; + readonly isDynamic: boolean; +} + +function parseImports(source: string): ParsedImport[] { + const parsed: ParsedImport[] = []; + const staticRe = /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s+["']([^"']+)["']/g; + const bareRe = /(?:^|\n)\s*import\s+["']([^"']+)["']/g; + const dynamicRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const match of source.matchAll(staticRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(bareRe)) { + parsed.push({ specifier: match[1]!, isDynamic: false }); + } + for (const match of source.matchAll(dynamicRe)) { + parsed.push({ specifier: match[1]!, isDynamic: true }); + } + return parsed; +} + +type Layer = "state" | "lib" | "components" | "native" | "features" | "other"; + +function layerOf(relativePath: string): Layer { + const top = relativePath.split(NodePath.sep)[0]!; + if (top === "features") return "features"; + return top === "state" || top === "lib" || top === "components" || top === "native" + ? (top as Layer) + : "other"; +} + +interface PlatformGraph { + readonly platform: Platform; + readonly files: ReadonlyArray; + /** Static (type or value) import edges keyed by source file. */ + readonly staticEdges: ReadonlyMap>; + /** Unique cross-layer edges, static and dynamic, as "fromRel -> toRel". */ + readonly crossLayerEdges: ReadonlySet; +} + +function buildPlatformGraph(platform: Platform): PlatformGraph { + const extensionOrder = PLATFORM_EXTENSION_ORDER[platform]; + const files = collectSourceFiles(SOURCE_ROOT) + .filter((file) => isRelevantForPlatform(file, platform)) + .sort(); + const staticEdges = new Map(); + const crossLayerEdges = new Set(); + for (const file of files) { + const targets = new Set(); + for (const { specifier, isDynamic } of parseImports(NodeFS.readFileSync(file, "utf8"))) { + const resolved = resolveRelative(file, specifier, extensionOrder); + if (resolved === null || resolved === file) { + continue; + } + const from = NodePath.relative(SOURCE_ROOT, file); + const to = NodePath.relative(SOURCE_ROOT, resolved); + const fromLayer = layerOf(from); + const toLayer = layerOf(to); + const upward = + (fromLayer === "state" || + fromLayer === "lib" || + fromLayer === "components" || + fromLayer === "native") && + (toLayer === "features" || (fromLayer === "lib" && toLayer === "state")); + if (upward) { + crossLayerEdges.add(`${from} -> ${to}`); + } + if (!isDynamic) { + targets.add(resolved); + } + } + staticEdges.set(file, [...targets]); + } + return { platform, files, staticEdges, crossLayerEdges }; +} + +/** Tarjan strongly-connected components, iterative to bound stack depth. */ +function findCycles(graph: PlatformGraph): ReadonlyArray> { + const index = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const cycles: string[][] = []; + let nextIndex = 0; + + const enter = (node: string): void => { + index.set(node, nextIndex); + low.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + }; + + for (const root of graph.files) { + if (index.has(root)) continue; + const work: Array<[string, number]> = [[root, 0]]; + enter(root); + while (work.length > 0) { + const frame = work[work.length - 1]!; + const neighbors = graph.staticEdges.get(frame[0]) ?? []; + let advanced = false; + for (let i = frame[1]; i < neighbors.length; i += 1) { + const child = neighbors[i]!; + if (!graph.staticEdges.has(child)) continue; + if (!index.has(child)) { + work[work.length - 1] = [frame[0], i + 1]; + work.push([child, 0]); + enter(child); + advanced = true; + break; + } else if (onStack.has(child)) { + low.set(frame[0], Math.min(low.get(frame[0])!, index.get(child)!)); + } + } + if (advanced) continue; + work.pop(); + const parent = work[work.length - 1]; + if (parent) { + low.set(parent[0], Math.min(low.get(parent[0])!, low.get(frame[0])!)); + } + if (low.get(frame[0]) === index.get(frame[0])) { + const component: string[] = []; + let member: string; + do { + member = stack.pop()!; + onStack.delete(member); + component.push(member); + } while (member !== frame[0]); + if (component.length > 1) { + cycles.push(component.sort().map((file) => NodePath.relative(SOURCE_ROOT, file))); + } + } + } + } + return cycles; +} + +const graphs = PLATFORMS.map(buildPlatformGraph); + +/** Unique upward edge pairs across every platform, sorted for stable diffs. */ +function upwardEdges(): string[] { + const union = new Set(); + for (const graph of graphs) { + for (const edge of graph.crossLayerEdges) { + union.add(edge); + } + } + return [...union].sort(); +} + +describe("mobile dependency graph", () => { + it.each(PLATFORMS)("has no circular imports under %s resolution", (platform) => { + const graph = graphs.find((candidate) => candidate.platform === platform)!; + // The graph must see real files; a resolution regression here would make + // both rules vacuously pass. + expect(graph.files.length).toBeGreaterThan(500); + expect(findCycles(graph)).toEqual([]); + }); + + it("keeps upward imports from state/lib/components/native into features at the ceiling", () => { + const edges = upwardEdges(); + const edgesFor = (from: Layer, to: Layer): string[] => + edges.filter((edge) => { + const [source, target] = edge.split(" -> "); + return layerOf(source!) === from && layerOf(target!) === to; + }); + + const ceilings: ReadonlyArray = [ + // state -> features: thread ordering reaching the thread-list model, + // the incoming-share store, the connection controller hook, the + // terminal launch context, and the pending message feed. + // (legacy-plan-mode was pure model logic and moved into state/.) + ["state", "features", 6, "state must not add imports from features"], + // lib -> features: lib/runtime.ts is the app composition root and + // legitimately wires cloud/observability features; the appearance + // helpers and terminal preferences still need untangling. + ["lib", "features", 7, "lib must not add imports from features"], + // components -> features: mostly the appearance preferences provider + // and the layout toolbar bridges. + ["components", "features", 33, "components must not add imports from features"], + // native -> features: native glue reading appearance/keyboard/review features. + ["native", "features", 8, "native must not add imports from features"], + // lib -> state: attachment/session plumbing that predates the cycle + // cleanup; each remaining edge needs a real owner-side seam. + ["lib", "state", 11, "lib must not add imports from state"], + ]; + + for (const [from, to, ceiling, message] of ceilings) { + const layerEdges = edgesFor(from, to); + expect( + layerEdges.length, + `${message}. ${layerEdges.length} edges remain:\n${layerEdges.join("\n")}`, + ).toBeLessThanOrEqual(ceiling); + } + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 9ffe586ecb81..a2a0945faaf1 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -6,6 +6,7 @@ interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; openLiveUpdateSettings?(): boolean; + showShowcaseActivity?(scheme: string, data: Record): void; } const native = @@ -17,18 +18,24 @@ export function supportsAndroidAgentNotifications(): boolean { return typeof native?.configure === "function" && typeof native?.clear === "function"; } +function appScheme(): string { + const scheme = Constants.expoConfig?.scheme; + return (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code"; +} + export function configureAndroidAgentNotifications( deviceId: string, userId: string, ongoingEnabled: boolean, ): void { - const scheme = Constants.expoConfig?.scheme; - native?.configure?.( - deviceId, - userId, - (Array.isArray(scheme) ? scheme[0] : scheme) ?? "t3code", - ongoingEnabled, - ); + native?.configure?.(deviceId, userId, appScheme(), ongoingEnabled); +} + +/** Posts a staged relay payload for the showcase capture; false when unsupported. */ +export function showAndroidShowcaseAgentActivity(data: Record): boolean { + if (!native?.showShowcaseActivity) return false; + native.showShowcaseActivity(appScheme(), data); + return true; } export function clearAndroidAgentNotifications(): void { diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index ea150180393c..b786f9f4066c 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -151,6 +151,7 @@ function ProjectGroupLabel(props: { ): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 2828392ba8f3..d362b78a9360 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -35,6 +35,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect interface CloudEnvironmentRowsProps { readonly connectedCloudEnvironments: ReadonlyArray; + readonly onOpenEnvironment?: (environmentId: EnvironmentId) => void; readonly onSetEnvironmentEnabled: (environmentId: EnvironmentId, enabled: boolean) => void; /** Long-press on a saved row. The callback owns the confirm. */ readonly onRemoveEnvironment: (environmentId: EnvironmentId) => void; @@ -124,26 +125,30 @@ function CloudEnvironmentRowsContent( ) : null} {hasCloudRows ? ( - - {props.connectedCloudEnvironments.map((environment, index) => ( + + {props.connectedCloudEnvironments.map((environment) => ( props.onSetEnvironmentEnabled(environment.environmentId, enabled) } onRemove={() => props.onRemoveEnvironment(environment.environmentId)} + onOpen={ + props.onOpenEnvironment + ? () => props.onOpenEnvironment?.(environment.environmentId) + : undefined + } errorExpanded={expandedErrorId === environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environmentId)} /> ))} - {availableCloudEnvironments.map((environment, index) => ( + {availableCloudEnvironments.map((environment) => ( 0 || index !== 0} + showChevron={props.onOpenEnvironment !== undefined} onConnect={() => handleConnectCloudEnvironment(environment)} errorExpanded={expandedErrorId === environment.environment.environmentId} onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} @@ -151,14 +156,14 @@ function CloudEnvironmentRowsContent( ))} ) : controller.relayDiscovery.isRefreshing ? ( - + Loading linked cloud environments. ) : controller.relayDiscovery.error ? null : ( - + No additional linked cloud environments. @@ -170,7 +175,7 @@ function CloudEnvironmentRowsContent( {discoveryAvailable && controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? ( - + Could not load T3 Connect environments @@ -202,10 +207,10 @@ function ConnectedCloudEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; /** Discovery's view of the server, for the glyph before the first connection. */ readonly descriptor: ExecutionEnvironmentDescriptor | undefined; - readonly borderTop: boolean; readonly errorExpanded: boolean; readonly onSetEnabled: (enabled: boolean) => void; readonly onRemove: () => void; + readonly onOpen?: (() => void) | undefined; readonly onToggleError: () => void; }) { const serverConfig = useAtomValue( @@ -222,10 +227,13 @@ function ConnectedCloudEnvironmentRow(props: { return ( void; readonly onToggleError: () => void; @@ -260,7 +268,7 @@ function CloudEnvironmentRow(props: { return ( + @@ -409,10 +412,16 @@ function CloudEnvironmentRowShell(props: { + {props.opensDetails || props.showChevron ? ( + + + + ) : null} ); } diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index cf90cd42a0ad..28b34fdec6d3 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -35,6 +35,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string export function ConnectionEnvironmentRow(props: { readonly environment: ConnectedEnvironmentSummary; readonly expanded: boolean; + readonly opensDetails?: boolean; readonly onToggle: () => void; readonly onReconnect: (environmentId: EnvironmentId) => void; readonly onRemove: (environmentId: EnvironmentId) => void; @@ -76,19 +77,22 @@ export function ConnectionEnvironmentRow(props: { }, [label, url, props]); return ( - + - - + - - {props.environment.displayUrl} - + {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? ( + + {props.environment.displayUrl} + + ) : null} {statusLabel ? ( props.onSetEnabled(props.environment.environmentId, next)} value={enabled} /> ({ + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/AppText", () => ({ + AppText: ({ + accessibilityElementsHidden, + importantForAccessibility, + children, + }: { + accessibilityElementsHidden?: boolean; + importantForAccessibility?: string; + children: ReactNode; + }) => ( + + {children} + + ), + AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( + + ), +})); + +import { ConnectionFormField } from "./ConnectionFormField"; + +describe("ConnectionFormField accessibility", () => { + it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain(``); + expect(markup).toContain(``); + }); +}); diff --git a/apps/mobile/src/features/connection/ConnectionFormField.tsx b/apps/mobile/src/features/connection/ConnectionFormField.tsx index 0d5e21148e54..b492dd9ce43c 100644 --- a/apps/mobile/src/features/connection/ConnectionFormField.tsx +++ b/apps/mobile/src/features/connection/ConnectionFormField.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; import { cn } from "../../lib/cn"; -type ConnectionFormFieldProps = Omit & { +type ConnectionFormFieldProps = Omit & { readonly label: string; readonly className?: string; }; @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit & { export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { return ( - + {label} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 5c58dfc1eaf9..bfcaf4013ab1 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -264,7 +264,7 @@ export function ConnectionsNewRouteScreen({ {pairingConnectionError ? : null} - + - {[...catalog.entries.values()].map((entry, index) => { + {[...catalog.entries.values()].map((entry) => { const environmentId = entry.target.environmentId; const selected = gitHubRoutingPermissionFor(entry, permissions); const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; return ( - + { setSaving(true); void update({ environmentId, permission: option.value }).then((result) => { diff --git a/apps/mobile/src/features/connection/LocalEnvironmentList.tsx b/apps/mobile/src/features/connection/LocalEnvironmentList.tsx index dca29e092f64..bec038390a9e 100644 --- a/apps/mobile/src/features/connection/LocalEnvironmentList.tsx +++ b/apps/mobile/src/features/connection/LocalEnvironmentList.tsx @@ -4,7 +4,6 @@ import { View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; type EnvironmentRowProps = ComponentProps; @@ -15,14 +14,20 @@ export function LocalEnvironmentList({ expandedId, onToggle, ...rowActions -}: Pick & { +}: Pick< + EnvironmentRowProps, + "onReconnect" | "onRemove" | "onSetEnabled" | "onUpdate" | "opensDetails" +> & { readonly environments: ReadonlyArray; readonly expandedId: EnvironmentId | null; readonly onToggle: (environmentId: EnvironmentId) => void; }) { if (environments.length === 0) { return ( - + - {environments.map((environment, index) => ( - + + {environments.map((environment) => ( + void; readonly leading?: ReactNode; }) { - const insets = useSafeAreaInsets(); + const { paddingTop, paddingBottom } = useMaterialToolbarLayout(); const searchRef = useRef(null); const [searchOpen, setSearchOpen] = useState(false); const searching = searchOpen || props.searchQuery.length > 0; @@ -77,11 +77,8 @@ export function MaterialFilesHeader(props: { /> {searching ? ( - - + + ); - return Platform.OS === "android" ? ( - - {content} - - ) : ( - content - ); + return Platform.OS === "android" ? {content} : content; } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index c3c906e3d515..dfe0c05743c2 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -147,7 +147,7 @@ export function ThreadFileNavigatorPane(props: { } > {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx index 012feb5ff68b..bf466a78e978 100644 --- a/apps/mobile/src/features/home/AndroidHomeFab.android.tsx +++ b/apps/mobile/src/features/home/AndroidHomeFab.android.tsx @@ -1,14 +1,23 @@ import { useCallback, useRef, useState, type ComponentProps } from "react"; -import { View, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { + useWindowDimensions, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton"; import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared"; import { useWorkspaceState } from "../../state/workspace"; import { MaterialFabScrollContext } from "./MaterialFabScrollContext"; import { updateMaterialFabScroll } from "./material-fab-scroll"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; export function AndroidHomeFabLayout(props: ComponentProps) { const insets = useSafeAreaInsets(); + const { appearance } = useAppearancePreferences(); + const { fontScale } = useWindowDimensions(); + const [layoutWidth, setLayoutWidth] = useState(null); const { state } = useWorkspaceState(); const [expanded, setExpanded] = useState(true); const scrollState = useRef({ anchor: 0, expanded: true }); @@ -23,11 +32,13 @@ export function AndroidHomeFabLayout(props: ComponentProps + setLayoutWidth(event.nativeEvent.layout.width)}> {props.children} - {state.hasConnections ? ( + {state.hasConnections && layoutWidth !== null ? ( ( () => [ { @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - ] satisfies MenuAction[])), - ], - [ - props.environments, - props.projectSortOrder, - props.projects, - props.selectedEnvironmentId, - props.selectedProjectKey, - props.threadSortOrder, - threadListV2Enabled, ], + [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { } return; } - - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => id === `project-sort:${option.value}`, - ); - if (projectSort) { - props.onProjectSortOrderChange(projectSort.value); - return; - } - - const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); - if (threadSort) { - props.onThreadSortOrderChange(threadSort.value); - return; - } }, [props], ); diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 2f044862f703..e1cc7448eb59 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useCallback, useRef } from "react"; import type { SearchBarCommands } from "react-native-screens"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { @@ -10,11 +9,6 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + // The list uses a fixed creation order and ignores sort/group options, so + // the filter menu only carries the filters and the "customized" icon state + // keys off those alone. + const hasCustomListOptions = + props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu({ - ...props, - listOrganization: !threadListV2Enabled, - }); + const filterMenu = buildHomeListFilterMenu(props); return ( <> @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( ) : null} - - {threadListV2Enabled ? null : ( - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} - - {threadListV2Enabled ? null : ( - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 1ef8ea7b286b..05070397cef5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,6 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, @@ -76,12 +77,8 @@ export function HomeRouteScreen() { () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { - options: listOptions, - setSelectedEnvironmentId, - setProjectSortOrder, - setThreadSortOrder, - } = useHomeListOptions(availableEnvironmentIds); + const { options: listOptions, setSelectedEnvironmentId } = + useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( @@ -175,8 +172,6 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} - projectSortOrder={listOptions.projectSortOrder} - threadSortOrder={listOptions.threadSortOrder} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => @@ -191,10 +186,8 @@ export function HomeRouteScreen() { params: { screen: "Settings" }, }) } - onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} projects={projects} @@ -262,7 +254,6 @@ export function HomeRouteScreen() { selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} threads={threads} - threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a01f5fbe606e..75be079f0de8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,10 +1,7 @@ +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; -import { createThreadMovePlanner } from "../threads/threadOrder"; -import { - LegendList, - type LegendListRef, - type LegendListRenderItemProps, -} from "@legendapp/list/react-native"; +import { computeThreadMoveAvailability } from "../threads/threadOrder"; +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -17,13 +14,18 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, type SidebarProjectGroupingMode, - type SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Platform, View } from "react-native"; +import { + ActivityIndicator, + Platform, + View, + type GestureResponderEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -34,20 +36,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "../threads/thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -55,33 +49,24 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; -import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; import { buildHomeProjectScopes, - buildHomeThreadGroups, sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; +import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useMaterialFabScroll } from "./MaterialFabScrollContext"; @@ -100,13 +85,10 @@ interface HomeScreenProps { readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; @@ -123,6 +105,10 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onSetThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -137,7 +123,25 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ -const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards +// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line +// cards ~94dp. The estimate seeds the recycler's initial container count, +// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the +// initial draw distance capped at 50, so an estimate at or below the average +// row height starts the pool at or above the item count for the short lists +// that LegendList otherwise keeps pooling to exactly its item count — that is +// what stopped the dev-mode "no unused container available" warning on the +// seeded short-list device passes. It is a mitigation, not an elimination: +// after first layout the full drawDistance applies, and a sudden expansion +// past the pooled headroom (~25+ items appearing at once) still creates a +// container on demand with the dev-only warning one pass ahead of the +// measured-height pool expansion. The old tallest-card estimate (~92) fired +// that warning on every ordinary shelf expand, so the average wins. +const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; +// Rows away from the viewport are cheap dormant frames (see +// swipe-row-activation), so render further ahead: a fast fling then reaches +// rows that are already built instead of rows still being rebuilt. +const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android @@ -220,16 +224,10 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = useThreadListV2Enabled(); const queuedThreadKeys = useQueuedThreadKeys(); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); - const listRef = useRef(null); const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED ? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT @@ -263,42 +261,6 @@ export function HomeScreen(props: HomeScreenProps) { () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); - const effectiveGroupDisplayStates = useMemo(() => { - const next = new Map(groupDisplayStates); - if (!AsyncResult.isSuccess(preferencesResult)) { - return next; - } - for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { - const existing = next.get(key); - next.set(key, { - ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), - collapsed: true, - }); - } - return next; - }, [groupDisplayStates, preferencesResult]); - const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); - effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; - - const updateGroupDisplay = useCallback( - (key: string, action: HomeGroupDisplayAction) => { - const next = new Map(effectiveGroupDisplayStatesRef.current); - next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); - effectiveGroupDisplayStatesRef.current = next; - setGroupDisplayStates(next); - if (action === "toggle-collapsed") { - const collapsedProjectGroups: string[] = []; - for (const [groupKey, state] of next) { - if (state.collapsed) { - collapsedProjectGroups.push(groupKey); - } - } - savePreferences({ collapsedProjectGroups }); - } - }, - [savePreferences], - ); - const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -316,8 +278,45 @@ export function HomeScreen(props: HomeScreenProps) { openSwipeableRef.current?.close(); }, []); const onMaterialFabScroll = useMaterialFabScroll(); + const listRef = useRef(null); + const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); + const activateVisibleRows = useCallback( + (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }, + [swipeRowActivation], + ); + // Status-bar, accessibility and programmatic scrolls never arm the scroll + // gate, so every scroll also activates the visible rows once it settles. + const activationTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(activationTimerRef.current), []); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + onMaterialFabScroll?.(event); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }, + [activateVisibleRows, onMaterialFabScroll], + ); + const trackListTouches = useCallback( + (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => touch.identifier) : [], + touches.map((touch) => touch.identifier), + ); + }, + [swipeRowActivation], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, + onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -330,112 +329,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], ); - const selectedProjectScope = useMemo( - () => - props.selectedProjectKey === null - ? null - : (projectScopes.find( - (scope) => - scope.key === props.selectedProjectKey || - scope.projectRefs.some( - (projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId) === - props.selectedProjectKey, - ), - ) ?? null), - [projectScopes, props.selectedProjectKey], - ); - const selectedProjectRefKeys = useMemo( - () => - selectedProjectScope === null - ? null - : new Set( - selectedProjectScope.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), - ), - [selectedProjectScope], - ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.projects - : props.projects.filter((project) => - selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, props.projects, selectedProjectRefKeys], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.threads - : props.threads.filter((thread) => - selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, props.threads, selectedProjectRefKeys], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.pendingTasks - : props.pendingTasks.filter((pendingTask) => - selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], - ); - - const projectGroups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: props.projectSortOrder, - threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - props.projectGroupingMode, - props.projectSortOrder, - props.searchQuery, - props.selectedEnvironmentId, - props.threadSortOrder, - matchedThreadKeys, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], - ); - const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { @@ -539,6 +433,12 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onUnpinThread], ); + const handleSetThreadAutoSettle = useCallback( + (thread: EnvironmentThreadShell, enabled: boolean) => { + void props.onSetThreadAutoSettle(thread, enabled); + }, + [props.onSetThreadAutoSettle], + ); const handleRegenerateThreadTitle = useCallback( (thread: EnvironmentThreadShell) => { void props.onRegenerateThreadTitle(thread); @@ -581,12 +481,11 @@ export function HomeScreen(props: HomeScreenProps) { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useFocusEffect( useCallback(() => { - if (!threadListV2Enabled) return; // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [threadListV2Enabled]), + }, []), ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). @@ -618,6 +517,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -655,12 +563,19 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: props.threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -680,7 +595,7 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, props.threads, @@ -692,16 +607,6 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -734,7 +639,6 @@ export function HomeScreen(props: HomeScreenProps) { props.selectedEnvironmentId, props.threads, matchedThreadKeys, - threadListV2Enabled, v2ScopedProjectGroup, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -783,21 +687,31 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }), - [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], + [ + nowMinute, + queuedThreadKeys, + threadMoveAvailability, + settledShelfExpanded, + shelfPreferencesLoaded, + snoozedShelfExpanded, + snoozeEnvironmentIds, + threadListV2Layout, + v2PendingTasks, + ], ); - useThreadJumpShortcuts( - threadListV2Enabled ? threadListV2Items : listLayout.items, - props.onSelectThread, - ); + useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + useEffect(() => { + if (swipeEnabled) activateVisibleRows(threadListV2Items); + }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = useCallback( - ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { - const nextItem = threadListV2Items[index + 1]; - const showTrailingDivider = - nextItem?.type === "v2-thread" || - (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); + ({ item }: { readonly item: ThreadListV2ListItem }) => { if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.environmentId, @@ -816,7 +730,7 @@ export function HomeScreen(props: HomeScreenProps) { } environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} showPendingDivider={item.showPendingDivider} - showTrailingDivider={showTrailingDivider} + showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} /> @@ -826,7 +740,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -836,33 +750,32 @@ export function HomeScreen(props: HomeScreenProps) { return ( ); } const thread = item.item.thread; - const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; return ( 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -886,30 +799,30 @@ export function HomeScreen(props: HomeScreenProps) { onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} + activationKey={item.key} /> ); }, [ handleDeleteThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, handleMoveThread, handlePinThread, handleRegenerateThreadTitle, @@ -921,6 +834,8 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + handleSetThreadAutoSettle, + autoSettleOptOutEnvironmentIds, pinningEnvironmentIds, machineByEnvironmentId, pinReorderEnvironmentIds, @@ -931,25 +846,23 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.onNewThreadOnBranch, props.savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, + resolveProviderInstance, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Items, threadSearchMatchByKey, titleRegenerationEnvironmentIds, toggleSettledShelf, toggleSnoozedShelf, v2ProjectTitleByProjectKey, props.searchQuery, - nowMinute, ], ); const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); - // FlatList treats a changed extraData identity as "re-render every visible - // row", so an inline object literal would invalidate all rows on every - // HomeScreen render. + // FlatList/LegendList treat a changed extraData identity as "re-render every + // visible row", so an inline object literal would invalidate all rows on + // every HomeScreen render — and the minute clock must stay out of it for + // the same reason: the clock text is precomputed per item instead. const v2ExtraData = useMemo( () => ({ projectByKey, @@ -957,7 +870,6 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -965,132 +877,18 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], ); - const extraData = useMemo( - () => ({ - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - threadSearchMatchByKey, - }), - [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], - ); - - const renderItem = useCallback( - ({ item }: LegendListRenderItemProps) => { - switch (item.type) { - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); - } - }, - [ - handleSwipeableClose, - handleSwipeableWillOpen, - handleRegenerateThreadTitle, - handleRenameThread, - machineByEnvironmentId, - queuedThreadKeys, - props.onArchiveThread, - props.onDeletePendingTask, - props.onDeleteThread, - props.onNewThreadInProject, - props.onSelectPendingTask, - props.onSelectThread, - props.onNewThreadOnBranch, - props.searchQuery, - props.savedConnectionsById, - threadSearchMatchByKey, - titleRegenerationEnvironmentIds, - updateGroupDisplay, - ], - ); - - const keyExtractor = useCallback((item: HomeListItem) => item.key, []); - /* Empty states */ // The signal must ignore the search/environment filters: an active query // that matches nothing needs the in-list "No results" state, not the // full-page "No threads yet". Settled threads are unarchived live shells, - // so the v1 check already covers v2. + // so the archived-at check already covers the settled shelf. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null @@ -1106,7 +904,7 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - + - ) : selectedProjectScope !== null ? ( + ) : v2ScopedProjectGroup !== null ? ( @@ -1178,92 +978,23 @@ export function HomeScreen(props: HomeScreenProps) { detail="Create a task to start a new coding session." variant={Platform.OS === "android" ? "plain" : undefined} /> - ) - ) : null; - // Use the v2 project scope for its empty state. Snoozed threads need no - // special empty state: their shelf header is a list row even while collapsed. - const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - - ) : v2ScopedProjectGroup !== null ? ( - - ) : ( - listEmpty ); - if ( - Platform.OS === "android" && - (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) - ) { + if (Platform.OS === "android" && threadListV2Items.length === 0) { return ( - {threadListV2Enabled ? v2ListEmpty : listEmpty} - - - ); - } - - if (threadListV2Enabled) { - return ( - - - - 0 ? ( - - ) : null - } - ListEmptyComponent={v2ListEmpty} - style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} - contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} - showsVerticalScrollIndicator={false} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - {...scrollGateHandlers} - scrollEventThrottle={16} - contentContainerStyle={{ - paddingBottom: - Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), - }} - /> - + {v2ListEmpty} ); } return ( - + - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} - + {/* Shared with the iPad sidebar: cells are reused across data + rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated + shell update) from re-rendering untouched rows. */} + activateVisibleRows(threadListV2Items)} + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} + data={threadListV2Items} + renderItem={renderV2Item} + keyExtractor={v2KeyExtractor} + getItemType={(item) => item.type} + itemsAreEqual={threadListV2ListItemsAreEqual} + estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} + drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} + recycleItems + extraData={v2ExtraData} + ListHeaderComponent={v2ListHeader} + ListFooterComponent={ + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( + + ) : null + } + ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} - recycleItems scrollEventThrottle={16} contentContainerStyle={{ - // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a - // standard 44pt bottom toolbar that overlays the list and is not - // reflected in insets while contentInsetAdjustmentBehavior is - // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? fabClearance : 88), }} - scrollIndicatorInsets={ - Platform.OS === "ios" - ? { - bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, - top: 0, - } - : undefined - } /> diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 72671bc046d3..6cea625aced9 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -12,7 +12,8 @@ import { MaterialSearchField } from "../../components/MaterialSearchField"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { WorkspaceConnectionTitle } from "./WorkspaceConnectionTitle"; import { useWorkspaceState } from "../../state/workspace"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; /** One toolbar height for the compact list and expanded sidebar, including search. */ export function MaterialThreadListToolbar(props: { @@ -28,7 +29,8 @@ export function MaterialThreadListToolbar(props: { readonly onRequestVisibility?: () => void; }) { const insets = useSafeAreaInsets(); - const toolbarHeight = useMaterialToolbarHeight(); + const { fabSize } = useAndroidControlSizing(); + const { height: toolbarHeight, ...headerPadding } = useMaterialToolbarLayout(); const { state } = useWorkspaceState(); const { onRequestVisibility, onSearchQueryChange } = props; const searchRef = useRef(null); @@ -76,11 +78,9 @@ export function MaterialThreadListToolbar(props: { {searching ? ( @@ -116,21 +116,21 @@ export function MaterialThreadListToolbar(props: { )} - {/* Sit 8dp above the 56dp extended New thread FAB. */} + {/* Keep the filter above the New thread FAB at every text size. */} {state.hasConnections ? ( {(open) => ( diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index 9b9333b46c7f..d2b068a07c59 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,9 +1,10 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Animated, Pressable, View } from "react-native"; +import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { brandTitleOffset, CompactBrandTitle, @@ -105,6 +106,7 @@ export function WorkspaceConnectionTitle(props: { }) { const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; + const { scale } = useAndroidControlSizing(); if (status === null) { return props.grow ? ( @@ -126,26 +128,28 @@ export function WorkspaceConnectionTitle(props: { hitSlop={8} onPress={props.onPress} className="flex-row items-center gap-2" - style={{ flexShrink: 1, marginLeft: props.statusOffset ?? 0 }} + style={[ + { flexShrink: 1, marginLeft: props.statusOffset ?? 0 }, + Platform.OS === "android" && { gap: 7 * scale }, + ]} > {status.showsProgress ? ( - + ) : ( )} {status.label} diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c072..0febe8e31031 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { ], selectedEnvironmentId: null, selectedProjectKey: "environment-1:project-1", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", onEnvironmentChange: vi.fn(), onProjectChange, - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), }); const projectMenu = menu.items.find( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f8627..ac53e427d21f 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,7 +1,4 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; - -import type { HomeProjectSortOrder } from "./homeThreadList"; -import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - /** False hides the sort/group submenus. Thread List v2 uses a fixed - creation-order layout, so offering those controls while it silently - ignores them would be a lie; the environment filter still applies. */ - readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: { }); } - if (props.listOrganization !== false) { - items.push( - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - ); - } - return { title: "Thread list options", items, diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts deleted file mode 100644 index ac3893956ca7..000000000000 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; - -const defaults: HomeListOptions = { - selectedEnvironmentId: null, - projectSortOrder: - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -}; - -describe("home list options", () => { - it("recognizes default options", () => { - expect(hasCustomHomeListOptions(defaults)).toBe(false); - }); - - it("marks environment filters as customized", () => { - expect( - hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), - ).toBe(true); - expect( - hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 14f842d73e2c..d280c05ee0b1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,12 +1,5 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; import { createContext, createElement, @@ -24,29 +17,12 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; } export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } -export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ - readonly value: HomeProjectSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - -export const THREAD_SORT_OPTIONS: ReadonlyArray<{ - readonly value: SidebarThreadSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, @@ -54,7 +30,6 @@ function defaultHomeListOptions(): HomeListOptions { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, }; } @@ -81,23 +56,6 @@ export function HomeListOptionsProvider({ return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions( - options: HomeListOptions & { - readonly selectedProjectKey?: string | null; - }, -): boolean { - const defaultProjectSortOrder = - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; - return ( - options.selectedEnvironmentId !== null || - (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || - options.projectSortOrder !== defaultProjectSortOrder || - options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER - ); -} - export function useHomeListOptions(availableEnvironmentIds: ReadonlySet) { const shared = useContext(HomeListOptionsContext); const [localOptions, setLocalOptions] = useState(defaultHomeListOptions); @@ -123,13 +81,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, projectSortOrder: value })); }, []); - const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { - setOptions((current) => ({ ...current, threadSortOrder: value })); - }, []); return { options: resolvedOptions, setSelectedEnvironmentId, setProjectSortOrder, - setThreadSortOrder, } as const; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts deleted file mode 100644 index d1e3315fd59a..000000000000 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - HOME_INITIAL_VISIBLE_THREADS, - HOME_SHOW_MORE_STEP, - nextGroupDisplayState, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; -import type { HomeThreadGroup } from "./homeThreadList"; -import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; - -const environmentId = EnvironmentId.make("environment-1"); - -function makeProject(id: string, title: string): EnvironmentProject { - return { - environmentId, - id: ProjectId.make(id), - title, - workspaceRoot: `/workspaces/${id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - }; -} - -function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { - return { - environmentId, - id: ThreadId.make(id), - projectId, - title: `Thread ${id}`, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - pullRequests: [], - latestTurn: null, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - archivedAt: null, - settledOverride: null, - settledAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }; -} - -function makeGroup(key: string, threadCount: number): HomeThreadGroup { - const project = makeProject(key, key); - const threads = Array.from({ length: threadCount }, (_, index) => - makeThread(`${key}-thread-${index}`, project.id), - ); - return { - key, - title: key, - representative: project, - projects: [project], - pendingTasks: [], - threads, - // All threads inside the recency window, so the baseline stays at the - // initial page size and the pagination expectations below hold. - recentThreads: threads, - newThreadTarget: project, - }; -} - -function itemTypes(items: ReadonlyArray): string[] { - return items.map((item) => item.type); -} - -function displayStates( - entries: Record, -): ReadonlyMap { - return new Map(Object.entries(entries)); -} - -describe("threadJumpTarget", () => { - it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)], - displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }), - }); - expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2"); - }); - - it("ignores missing positions and unrelated commands", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 1)], - displayStates: displayStates({}), - }); - expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull(); - expect(threadJumpTarget([], "thread.jump.1")).toBeNull(); - expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull(); - }); -}); - -describe("buildHomeListLayout", () => { - it("renders a header plus all threads for a small group without a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 3)], - displayStates: displayStates({}), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); - expect(layout.stickyHeaderIndices).toEqual([0]); - expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); - }); - - it("limits large groups to the initial visible count with a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 133)], - displayStates: displayStates({}), - }); - - const threadItems = layout.items.filter((item) => item.type === "thread"); - expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); - expect(layout.items.at(-1)).toMatchObject({ - type: "show-more", - groupKey: "alpha", - hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, - canShowLess: false, - }); - // The show-more row takes over the last slot, so no thread is marked last. - expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); - }); - - it("reveals more threads per show-more step and offers show-less when exhausted", () => { - const group = makeGroup("alpha", 20); - - const expandedOnce = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( - HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, - ); - expect(expandedOnce.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 4, - canShowLess: true, - }); - - const fullyExpanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - }), - }); - expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); - expect(fullyExpanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - - const reset = nextGroupDisplayState( - nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - "show-less", - ); - expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); - }); - - it("offers show-less after expanding a stale group whose baseline is below the page size", () => { - // Stale project: 10 threads total but only 3 within the recency window. - const project = makeProject("stale", "stale"); - const threads = Array.from({ length: 10 }, (_, index) => - makeThread(`stale-thread-${index}`, project.id), - ); - const group: HomeThreadGroup = { - key: "stale", - title: "stale", - representative: project, - projects: [project], - pendingTasks: [], - threads, - recentThreads: threads.slice(0, 3), - newThreadTarget: project, - }; - - const collapsedToRecent = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({}), - }); - expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3); - expect(collapsedToRecent.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 7, - canShowLess: false, - }); - - const expanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10); - expect(expanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - }); - - it("hides threads and the show-more row for collapsed groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); - expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); - expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); - expect(layout.stickyHeaderIndices).toEqual([0, 1]); - }); - - it("suspends collapse and pagination while searching", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - showAllThreads: true, - }); - - expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); - expect(layout.items.some((item) => item.type === "show-more")).toBe(false); - }); - - it("keeps sticky indices aligned across multiple expanded groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], - displayStates: displayStates({}), - }); - - // header + 6 threads + show-more = 8 items, so beta's header is index 8. - expect(layout.stickyHeaderIndices).toEqual([0, 8]); - expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); - }); -}); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts deleted file mode 100644 index 910ddb5896b7..000000000000 --- a/apps/mobile/src/features/home/homeListItems.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import type { HomeThreadGroup } from "./homeThreadList"; - -/** Threads shown per project before the "Show more" affordance appears. */ -export const HOME_INITIAL_VISIBLE_THREADS = 6; -/** Additional threads revealed per "Show more" tap. */ -export const HOME_SHOW_MORE_STEP = 10; - -export interface HomeGroupDisplayState { - readonly collapsed: boolean; - /** How many threads are currently revealed (clamped to the group size). */ - readonly visibleCount: number; -} - -export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { - collapsed: false, - visibleCount: HOME_INITIAL_VISIBLE_THREADS, -}; - -export interface HomeHeaderListItem { - readonly type: "header"; - readonly key: string; - readonly group: HomeThreadGroup; - readonly collapsed: boolean; - readonly isFirst: boolean; -} - -export interface HomeThreadListItem { - readonly type: "thread"; - readonly key: string; - readonly thread: EnvironmentThreadShell; - readonly isLast: boolean; -} - -export interface HomePendingTaskListItem { - readonly type: "pending-task"; - readonly key: string; - readonly pendingTask: PendingNewTask; - readonly isLast: boolean; -} - -export interface HomeShowMoreListItem { - readonly type: "show-more"; - readonly key: string; - readonly groupKey: string; - /** Threads still hidden. 0 means the group is fully expanded. */ - readonly hiddenCount: number; - /** Whether more than the initial count is revealed, so "Show less" applies. */ - readonly canShowLess: boolean; -} - -export type HomeListItem = - | HomeHeaderListItem - | HomePendingTaskListItem - | HomeThreadListItem - | HomeShowMoreListItem; - -export interface HomeListLayout { - readonly items: ReadonlyArray; - readonly stickyHeaderIndices: ReadonlyArray; -} - -export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] }; - -export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; - -export function nextGroupDisplayState( - current: HomeGroupDisplayState, - action: HomeGroupDisplayAction, -): HomeGroupDisplayState { - switch (action) { - case "toggle-collapsed": - return { ...current, collapsed: !current.collapsed }; - case "show-more": - return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; - case "show-less": - return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; - } -} - -/** - * Structural equality for list items. Item objects are rebuilt on every - * collapse/show-more toggle; without this the lists would consider every - * mounted row changed and re-render all of them (each carrying a swipeable + - * a vcs-status subscription). Group/thread references are stable across - * toggles. - */ -export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { - switch (item.type) { - case "header": - return ( - previous.type === "header" && - previous.group === item.group && - previous.collapsed === item.collapsed && - previous.isFirst === item.isFirst - ); - case "pending-task": - return ( - previous.type === "pending-task" && - previous.pendingTask === item.pendingTask && - previous.isLast === item.isLast - ); - case "thread": - return ( - previous.type === "thread" && - previous.thread === item.thread && - previous.isLast === item.isLast - ); - case "show-more": - return ( - previous.type === "show-more" && - previous.groupKey === item.groupKey && - previous.hiddenCount === item.hiddenCount && - previous.canShowLess === item.canShowLess - ); - } -} - -export function buildHomeListLayout(input: { - readonly groups: ReadonlyArray; - readonly displayStates: ReadonlyMap; - /** - * When searching, pagination is suspended so every match stays visible. - */ - readonly showAllThreads?: boolean; -}): HomeListLayout { - const items: HomeListItem[] = []; - const stickyHeaderIndices: number[] = []; - - for (const [groupIndex, group] of input.groups.entries()) { - const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; - const collapsed = display.collapsed && input.showAllThreads !== true; - - stickyHeaderIndices.push(items.length); - items.push({ - type: "header", - key: `header:${group.key}`, - group, - collapsed, - isFirst: groupIndex === 0, - }); - - if (collapsed) { - continue; - } - - const totalCount = group.threads.length; - // Default to the group's recent-activity window (last few days, or a small - // fallback for stale projects), capped at the initial page size. Until the - // user taps "Show more", older threads stay hidden to save vertical space; - // "Show less" resets visibleCount to the initial constant, which lands back - // here at the recency baseline. - const baselineCount = Math.min( - group.recentThreads.length, - HOME_INITIAL_VISIBLE_THREADS, - totalCount, - ); - const visibleCount = input.showAllThreads - ? totalCount - : Math.min( - display.visibleCount > HOME_INITIAL_VISIBLE_THREADS - ? display.visibleCount - : baselineCount, - totalCount, - ); - const visibleThreads = group.threads.slice(0, visibleCount); - const hiddenCount = totalCount - visibleCount; - const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount; - - // Pending (unsent) tasks lead the group and are never paginated away. - for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { - items.push({ - type: "pending-task", - key: pendingTask.key, - pendingTask, - isLast: - pendingIndex === group.pendingTasks.length - 1 && - visibleThreads.length === 0 && - !hasShowMoreRow, - }); - } - - for (const [threadIndex, thread] of visibleThreads.entries()) { - items.push({ - type: "thread", - key: `thread:${thread.environmentId}:${thread.id}`, - thread, - isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, - }); - } - - if (hasShowMoreRow) { - items.push({ - type: "show-more", - key: `show-more:${group.key}`, - groupKey: group.key, - hiddenCount, - // Compare against the group's own baseline, not the global page size: - // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show - // less" must be offered as soon as anything beyond the baseline shows. - canShowLess: visibleCount > baselineCount, - }); - } - } - - return { items, stickyHeaderIndices }; -} diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 0f7b14bf9365..98b32edfd748 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,15 +2,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - buildHomeProjectScopes, - buildHomeThreadGroups, - sortHomeProjectScopes, -} from "./homeThreadList"; +import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList"; function makeProject( input: Partial & Pick, @@ -52,27 +47,7 @@ function makeThread( }; } -const NOW = Date.parse("2026-06-29T00:00:00.000Z"); - -function buildGroups( - projects: ReadonlyArray, - threads: ReadonlyArray, - overrides: Partial[0]> = {}, -) { - return buildHomeThreadGroups({ - projects, - threads, - environmentId: null, - searchQuery: "", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - projectGroupingMode: "repository", - now: NOW, - ...overrides, - }); -} - -describe("buildHomeThreadGroups", () => { +describe("home project scopes", () => { it("builds one v2 scope for the same repository across environments", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -116,70 +91,6 @@ describe("buildHomeThreadGroups", () => { ); }); - it("routes stale duplicate project refs through the canonical repository group", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const local = makeProject({ - id: ProjectId.make("project-local"), - environmentId: localEnvironmentId, - title: "t3code", - workspaceRoot: "/workspaces/t3code", - repositoryIdentity, - }); - const stale = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-stale"), - title: "t3code", - workspaceRoot: "/remote/t3code", - updatedAt: "2026-06-01T00:00:00.000Z", - }); - const canonicalRemote = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-canonical-remote"), - title: "t3code", - workspaceRoot: "/remote/t3code/", - repositoryIdentity, - updatedAt: "2026-06-02T00:00:00.000Z", - }); - const projects = [local, stale, canonicalRemote]; - const staleThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-stale-project-ref"), - projectId: stale.id, - title: "Still visible", - updatedAt: "2026-06-03T00:00:00.000Z", - }); - - const scopes = buildHomeProjectScopes({ - projects, - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups(projects, [staleThread]); - - expect(scopes).toHaveLength(1); - expect(scopes[0]?.projects.map((project) => project.id)).toEqual([ - local.id, - canonicalRemote.id, - ]); - expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([ - local.id, - stale.id, - canonicalRemote.id, - ]); - expect(groups).toHaveLength(1); - expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]); - expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id); - }); - it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -375,410 +286,4 @@ describe("buildHomeThreadGroups", () => { }), ).toHaveLength(2); }); - - it("uses the physical project title for a singleton scope", () => { - const project = makeProject({ - environmentId: EnvironmentId.make("environment-1"), - id: ProjectId.make("project-1"), - title: "local-worktree-name", - repositoryIdentity: { - canonicalKey: "github.com/pingdotgg/t3code", - displayName: "codething-mvp", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }, - }); - - const scopes = buildHomeProjectScopes({ - projects: [project], - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups( - [project], - [ - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make("thread-1"), - projectId: project.id, - title: "Thread", - }), - ], - ); - - expect(scopes[0]?.title).toBe("local-worktree-name"); - expect(groups[0]?.title).toBe("local-worktree-name"); - }); - - it("sorts the newest thread first regardless of snapshot order", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("thread-old"), - projectId: project.id, - title: "Older thread", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("thread-new"), - projectId: project.id, - title: "Newer thread", - updatedAt: "2026-06-03T00:00:00.000Z", - }), - ]; - - expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ - "thread-new", - "thread-old", - ]); - }); - - it("supports independent project and thread creation-time sorting", () => { - const environmentId = EnvironmentId.make("environment-1"); - const olderProject = makeProject({ - environmentId, - id: ProjectId.make("project-older"), - title: "Older project", - }); - const newerProject = makeProject({ - environmentId, - id: ProjectId.make("project-newer"), - title: "Newer project", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("old-created"), - projectId: olderProject.id, - title: "Updated recently", - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-05T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("new-created"), - projectId: olderProject.id, - title: "Created recently", - createdAt: "2026-06-04T00:00:00.000Z", - updatedAt: "2026-06-04T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("newest-project-thread"), - projectId: newerProject.id, - title: "Newest project", - createdAt: "2026-06-06T00:00:00.000Z", - }), - ]; - - const groups = buildGroups([olderProject, newerProject], threads, { - projectSortOrder: "created_at", - threadSortOrder: "created_at", - projectGroupingMode: "separate", - }); - - expect(groups.map((group) => group.representative.id)).toEqual([ - "project-newer", - "project-older", - ]); - expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); - }); - - it("filters both projects and threads to one environment", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const projects = [ - makeProject({ - environmentId: localEnvironmentId, - id: ProjectId.make("project-local"), - title: "Local", - }), - makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-remote"), - title: "Remote", - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); - expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); - }); - - it("matches web repository, repository-path, and separate grouping modes", () => { - const environmentId = EnvironmentId.make("environment-1"); - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - provider: "github", - owner: "t3tools", - name: "t3code", - displayName: "T3 Code", - rootPath: "/workspaces/t3code", - }; - const projects = [ - makeProject({ - environmentId, - id: ProjectId.make("project-web"), - title: "Web", - workspaceRoot: "/workspaces/t3code/apps/web", - repositoryIdentity, - }), - makeProject({ - environmentId, - id: ProjectId.make("project-mobile"), - title: "Mobile", - workspaceRoot: "/workspaces/t3code/apps/mobile", - repositoryIdentity, - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect( - buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - expect( - buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - }); - - it("default view shows only threads from the last 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent-1"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("recent-2"), - projectId: project.id, - title: "Within window", - updatedAt: "2026-06-25T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads)[0]; - // Default view trims to recent threads... - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); - // ...while full history stays available for the expanded view. - expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); - }); - - it("keeps an old thread in the default view while a message waits in its outbox", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old-queued"), - projectId: project.id, - title: "Two weeks ago, follow-up queued offline", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-13T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads, { - queuedThreadKeys: new Set([`${environmentId}:old-queued`]), - })[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); - }); - - it("falls back to the most recent 3 threads when none are within 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads)[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ - "thread-4", - "thread-3", - "thread-2", - ]); - expect(group?.threads).toHaveLength(5); - }); - - it("does not apply the recency window while searching", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; - // Search reaches the full history rather than the 3-thread fallback. - expect(group?.recentThreads).toHaveLength(5); - expect(group?.recentThreads.map((thread) => thread.id)).toEqual( - group?.threads.map((thread) => thread.id), - ); - }); - - it("includes a thread matched by message content", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const thread = makeThread({ - environmentId, - id: ThreadId.make("thread-content"), - projectId: project.id, - title: "Unrelated title", - }); - - const groups = buildGroups([project], [thread], { - searchQuery: "relay reconnect", - matchedThreadKeys: new Set([ - threadSearchMatchKey({ - environmentId, - threadId: thread.id, - }), - ]), - }); - - expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); - }); - - it("targets quick new threads at the group member with the newest thread", () => { - const laptopEnv = EnvironmentId.make("environment-laptop"); - const desktopEnv = EnvironmentId.make("environment-desktop"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const laptopProject = makeProject({ - environmentId: laptopEnv, - id: ProjectId.make("project-laptop"), - title: "t3code", - repositoryIdentity, - }); - const desktopProject = makeProject({ - environmentId: desktopEnv, - id: ProjectId.make("project-desktop"), - title: "t3code", - repositoryIdentity, - }); - const threads = [ - makeThread({ - environmentId: laptopEnv, - id: ThreadId.make("thread-laptop"), - projectId: laptopProject.id, - title: "Older laptop thread", - updatedAt: "2026-06-27T00:00:00.000Z", - }), - makeThread({ - environmentId: desktopEnv, - id: ThreadId.make("thread-desktop"), - projectId: desktopProject.id, - title: "Newest desktop thread", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - ]; - - // Aggregated into one group by repository; the quick new-thread target - // must follow the newest thread (desktop), not the arbitrary first member. - const groups = buildGroups([laptopProject, desktopProject], threads); - expect(groups).toHaveLength(1); - expect(groups[0]?.projects).toHaveLength(2); - expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv); - expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id); - }); }); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 895c73bbe266..0c85972e43e5 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,31 +1,22 @@ -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; -import { - buildProjectGroups, - derivePhysicalProjectKey, - deriveProjectGroupLabel, -} from "@t3tools/client-runtime/state/project-grouping"; +import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { getThreadSortTimestamp, - sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, - SidebarThreadSortOrder, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; -import * as Option from "effect/Option"; import * as Order from "effect/Order"; -import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; @@ -133,260 +124,3 @@ export function sortHomeProjectScopes(input: { ), ); } - -/** - * Default home view only surfaces threads active within this window, to keep the - * screen compact while keeping recent work visible. - */ -const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; -/** Fallback when a project has no threads inside the recency window. */ -const RECENT_THREAD_FALLBACK_COUNT = 3; - -export interface HomeThreadGroup { - readonly key: string; - readonly title: string; - readonly representative: EnvironmentProject; - readonly projects: ReadonlyArray; - readonly pendingTasks: ReadonlyArray; - /** Full sorted thread history for the group (revealed when expanded / searching). */ - readonly threads: ReadonlyArray; - /** Subset shown by default: threads from the last few days, or the most recent few. */ - readonly recentThreads: ReadonlyArray; - /** - * Where a quick "new thread in this project" should land. For aggregated - * groups (same repo on several machines) this is the member that owns the - * group's most recent thread — the machine the user last worked on — rather - * than the arbitrary first member; the draft's computer picker covers - * switching from there. Null only for synthetic pending-project groups, - * whose single "project" is a placeholder built from queued-task metadata. - */ - readonly newThreadTarget: EnvironmentProject | null; -} - -interface MutableHomeThreadGroup { - readonly key: string; - readonly projects: EnvironmentProject[]; - readonly pendingTasks: PendingNewTask[]; - readonly threads: EnvironmentThreadShell[]; -} - -function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { - const latestThread = group.threads.reduce( - (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), - Number.NEGATIVE_INFINITY, - ); - return group.pendingTasks.reduce((latest, pendingTask) => { - const timestamp = Date.parse(pendingTask.createdAt); - return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); - }, latestThread); -} - -/** - * Trims a group's threads to recent activity for the default home view. - * `sortedThreads` must already be ordered newest-first for `threadSortOrder`. - * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps - * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes. - */ -function selectRecentThreads( - sortedThreads: ReadonlyArray, - threadSortOrder: SidebarThreadSortOrder, - now: number, - queuedThreadKeys: ReadonlySet | undefined, -): ReadonlyArray { - const cutoff = now - RECENT_THREAD_WINDOW_MS; - // A thread with a message waiting in the outbox has work the user is - // waiting on, however old its last activity; it never trims away. - const recent = sortedThreads.filter( - (thread) => - getThreadSortTimestamp(thread, threadSortOrder) >= cutoff || - queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true, - ); - return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); -} - -export function buildHomeThreadGroups(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly pendingTasks?: ReadonlyArray; - /** Thread keys with a message waiting in the outbox; kept in the default view. */ - readonly queuedThreadKeys?: ReadonlySet; - readonly environmentId: EnvironmentId | null; - readonly searchQuery: string; - readonly matchedThreadKeys?: ReadonlySet; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; - /** Current time used for the recency window; defaults to now. Injectable for tests. */ - readonly now?: number; -}): ReadonlyArray { - const now = input.now ?? Date.now(); - const groups = new Map(); - const groupTitleByKey = new Map(); - const groupKeyByProjectKey = new Map(); - - for (const scope of buildHomeProjectScopes(input)) { - groupTitleByKey.set(scope.key, scope.title); - groups.set(scope.key, { - key: scope.key, - projects: [...scope.projects], - pendingTasks: [], - threads: [], - }); - for (const projectRef of scope.projectRefs) { - groupKeyByProjectKey.set( - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - scope.key, - ); - } - } - - for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId); - let groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - // The project shell is not loaded (environment offline / project gone). - // A queued task must stay visible and deletable regardless, so build a - // standalone group from the metadata snapshotted at enqueue time. - groupKey = `pending-project:${physicalKey}`; - groupKeyByProjectKey.set(physicalKey, groupKey); - groups.set(groupKey, { - key: groupKey, - projects: [ - { - environmentId: pendingTask.environmentId, - id: pendingTask.projectId, - title: pendingTask.projectTitle ?? "Unknown project", - workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId), - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: pendingTask.createdAt, - updatedAt: pendingTask.createdAt, - }, - ], - pendingTasks: [], - threads: [], - }); - } - groups.get(groupKey)?.pendingTasks.push(pendingTask); - } - - for (const thread of input.threads) { - if (thread.archivedAt !== null) { - continue; - } - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); - const groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - continue; - } - groups.get(groupKey)?.threads.push(thread); - } - - const query = input.searchQuery.trim().toLocaleLowerCase(); - const result: HomeThreadGroup[] = []; - - for (const group of groups.values()) { - const representative = group.projects[0]; - if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { - continue; - } - - const title = - groupTitleByKey.get(group.key) ?? - deriveProjectGroupLabel({ representative, members: group.projects }); - const groupMatches = - query.length === 0 || - title.toLocaleLowerCase().includes(query) || - group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); - const matchingThreads = groupMatches - ? group.threads - : group.threads.filter( - (thread) => - thread.title.toLocaleLowerCase().includes(query) || - threadPullRequestSearchTerms(thread).some((term) => - term.toLocaleLowerCase().includes(query), - ) || - input.matchedThreadKeys?.has( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ) === true, - ); - const matchingPendingTasks = groupMatches - ? group.pendingTasks - : group.pendingTasks.filter((pendingTask) => - pendingTask.title.toLocaleLowerCase().includes(query), - ); - - if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { - continue; - } - - const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder); - // An active search should reach the full history, so the recency window - // only trims the default (no-query) view. - const recentThreads = - query.length === 0 - ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys) - : sortedThreads; - - // A stale project id still resolves to the canonical member with the same - // environment/path, so quick creation follows the machine with the newest activity. - const lastActiveProject = Arr.head(sortedThreads).pipe( - Option.flatMap((thread) => - Arr.findFirst( - input.projects, - (project) => - project.environmentId === thread.environmentId && project.id === thread.projectId, - ), - ), - Option.flatMap((threadProject) => - Arr.findFirst( - group.projects, - (project) => - derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), - ), - ), - Option.getOrNull, - ); - - result.push({ - key: group.key, - title, - representative, - projects: group.projects, - pendingTasks: matchingPendingTasks, - threads: sortedThreads, - recentThreads, - newThreadTarget: group.key.startsWith("pending-project:") - ? null - : (lastActiveProject ?? representative), - }); - } - - return Arr.sort( - result, - Order.mapInput( - Order.Struct({ - timestamp: Order.flip(Order.Number), - title: Order.String, - key: Order.String, - }), - (group: HomeThreadGroup) => ({ - timestamp: groupSortTimestamp(group, input.projectSortOrder), - title: group.title, - key: group.key, - }), - ), - ); -} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts new file mode 100644 index 000000000000..43f8dcf0ca7e --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSwipeRowActivation } from "./swipe-row-activation"; + +describe("createSwipeRowActivation", () => { + it("activates exactly the requested rows and notifies only on change", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + activation.subscribe(listener); + + activation.activate(["a", "b"]); + activation.activate(["b", "a"]); + + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("c")).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("defers changes while a finger is on the list so a press is never remounted", () => { + const activation = createSwipeRowActivation(); + activation.activate(["a"]); + + activation.trackTouches(["1"], ["1"]); + activation.activate(["b"]); + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("b")).toBe(false); + expect(activation.isActive("c")).toBe(true); + }); + + it("ignores fingers that did not start on the list", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1", "2"]); + activation.activate(["a"]); + + // The list finger lifts while finger 2 stays on another control. + activation.trackTouches([], ["2"]); + expect(activation.isActive("a")).toBe(true); + }); + + it("drops a list finger whose end event never arrived", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1"]); + activation.activate(["a"]); + + activation.trackTouches(["2"], ["2"]); + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(true); + }); + + it("stops notifying after unsubscribe", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + const unsubscribe = activation.subscribe(listener); + unsubscribe(); + activation.activate(["a"]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts new file mode 100644 index 000000000000..d56d12cba56f --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -0,0 +1,62 @@ +import { createContext, use, useSyncExternalStore } from "react"; + +/** + * Full swipe rows (pan gesture, animated actions, hidden action buttons) only + * exist around the viewport. Every other Home row renders a dormant frame that + * paints the same content with a fraction of the native views, so a row the + * list rebuilds while scrolling is cheap. The scroll gate already disables + * swipes while the list moves, so rows are activated once it rests. + */ +export function createSwipeRowActivation() { + let activeKeys = new Set(); + // Swapping a row's frame remounts it, which would cancel a press or long + // press in progress, so changes wait until every finger that started on the + // list has lifted. + const listTouches = new Set(); + let pendingKeys: ReadonlyArray | null = null; + const listeners = new Set<() => void>(); + const apply = (keys: ReadonlyArray) => { + if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + activeKeys = new Set(keys); + for (const listener of listeners) listener(); + }; + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + isActive: (key: string) => activeKeys.has(key), + activate(keys: ReadonlyArray) { + if (listTouches.size > 0) pendingKeys = keys; + else apply(keys); + }, + /** + * `started` are touches that just began on the list; `onScreen` is every + * finger still down anywhere. A finger on another control never holds + * changes, and one whose end event went missing is dropped here. + */ + trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { + for (const id of started) listTouches.add(id); + for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); + if (listTouches.size > 0 || pendingKeys === null) return; + const keys = pendingKeys; + pendingKeys = null; + apply(keys); + }, + }; +} + +export type SwipeRowActivation = ReturnType; + +export const SwipeRowActivationContext = createContext(null); + +const subscribeNever = () => () => {}; + +/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ +export function useSwipeRowDormant(key: string | undefined): boolean { + const activation = use(SwipeRowActivationContext); + return useSyncExternalStore( + activation?.subscribe ?? subscribeNever, + () => activation !== null && key !== undefined && !activation.isActive(key), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..e62935c28861 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; + readonly activation?: SwipeRowActivation; readonly children: ReactNode; }) { return ( - {props.children} + + {props.children} + ); } @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { * open/mid-drag state can't leak onto another row. */ readonly resetKey?: string; + /** Paints the row without swipe machinery; see swipe-row-activation. */ + readonly dormant?: boolean; readonly simultaneousWithExternalGesture?: ComponentProps< typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; readonly threadTitle: string; } +const closeDormant = () => {}; + export function ThreadSwipeable(props: ThreadSwipeableProps) { + if (props.dormant) { + // Mirrors ReanimatedSwipeable's container and children views. + return ( + + + {props.children(closeDormant)} + + + ); + } // Recycled content gets fresh native and animation state. Late callbacks // from the previous row retain its action, never the replacement's action. return ; diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 9b599110883c..4a43facc6172 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -59,6 +59,15 @@ function environmentSupportsPinReorder(environmentId: EnvironmentThreadShell["en ); } +function environmentSupportsAutoSettleOptOut( + environmentId: EnvironmentThreadShell["environmentId"], +) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadAutoSettleOptOut === true + ); +} + function environmentSupportsTitleRegeneration( environmentId: EnvironmentThreadShell["environmentId"], ) { @@ -237,6 +246,11 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; + /** Sets per-thread automatic settlement on or off. */ + readonly setThreadAutoSettle: ( + thread: EnvironmentThreadShell, + enabled: boolean, + ) => Promise; readonly moveThread: ( thread: EnvironmentThreadShell, direction: ThreadMoveDestination, @@ -249,6 +263,9 @@ export function useThreadListActions(): { const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); + const setAutoSettleMutation = useAtomCommand(threadEnvironment.setAutoSettle, { + reportFailure: false, + }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -435,6 +452,34 @@ export function useThreadListActions(): { }, [unpinMutation], ); + const setThreadAutoSettle = useCallback( + async (thread: EnvironmentThreadShell, enabled: boolean) => { + if (!environmentSupportsAutoSettleOptOut(thread.environmentId)) { + Alert.alert( + "Could not update auto-settle", + "This environment's server does not support turning auto-settle off per thread yet. Update the server to use it.", + ); + return false; + } + selectionHaptic(); + const result = await setAutoSettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, enabled }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not update auto-settle", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The auto-settle setting could not be changed.", + ); + return false; + } + return true; + }, + [setAutoSettleMutation], + ); const regenerateThreadTitle = useCallback( async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); @@ -698,6 +743,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index b76d2f8a90bf..45b6ccb13d17 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -2,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { useCallback } from "react"; -import type { HomeListItem } from "../home/homeListItems"; import type { ThreadListV2ListItem } from "../threads/threadListV2"; import { useHardwareKeyboardCommand, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; -type ThreadShortcutListItem = - | HomeListItem - | ThreadListV2ListItem - | { readonly type: "v2-show-more" }; +type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" }; export function threadJumpIndex(command: HardwareKeyboardCommand) { return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); } -/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */ +/** Uses the rendered list so filters and shelves keep their order. */ export function threadJumpTarget( items: ReadonlyArray, command: HardwareKeyboardCommand, @@ -26,8 +22,7 @@ export function threadJumpTarget( let index = threadJumpIndex(command); if (index < 0) return null; for (const item of items) { - const thread = - item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; + const thread = item.type === "v2-thread" ? item.item.thread : null; if (thread !== null && index-- === 0) return thread; } return null; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 6c6133c7ef13..60df38b30357 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -57,6 +57,7 @@ import { import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; @@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: { style={inspectorStyle} > - {props.renderInspector?.()} + ( + + )} + > + + ) : null} ); } + +// The render callback must run inside the boundary's child, not while its +// parent constructs the boundary element. +function InspectorRenderer(props: { readonly render?: () => ReactNode }) { + return <>{props.render?.()}; +} diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index eb73abba292b..ae204413e777 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -25,7 +25,7 @@ export function resolveTracingConfig(): TracingConfig | null { export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { - serviceName: "t3-mobile-relay-client", + serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", client: `mobile-${resource.appVariant}`, diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2fac5376e5b1..08888dd469d2 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -735,7 +735,7 @@ export function ReviewSheet(props: ReviewSheetProps) { /> - + {showConnectionNotice ? ( ) { + // Key local request state to the host even when navigation reuses this screen. + return ( + + ); +} + +function EnvironmentDetail({ environmentId }: { readonly environmentId: EnvironmentId }) { + const insets = useSafeAreaInsets(); + const connections = useRemoteConnections(); + const environment = connections.connectedEnvironments.find( + (entry) => entry.environmentId === environmentId, + ); + const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const session = useAtomValue(environmentSession.sessionStateValueAtom(environmentId)); + const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const updateState = useAtomValue(serverEnvironment.updateStateAtom(environmentId)); + const updateServer = useAtomCommand(serverEnvironment.updateServer); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders); + const [connectionExpanded, setConnectionExpanded] = useState(false); + const [pending, setPending] = useState(null); + const pendingRef = useRef(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [release, setRelease] = useState<{ + fromVersion: string; + targetVersion: string | null; + } | null>(null); + const checkController = useRef(null); + useEffect(() => () => checkController.current?.abort(), []); + + const connected = environment?.isEnabled === true && environment.connectionState === "connected"; + const allowed = + !AsyncResult.isFailure(sessionResult) && canMaintainEnvironment(session, connected); + const running = updateState.status === "running"; + const providerBusy = + config?.providers.some( + (provider) => + provider.updateState?.status === "running" || provider.updateState?.status === "queued", + ) ?? false; + const disabled = !allowed || pending !== null || running || providerBusy; + const version = config?.environment.serverVersion; + const checkedRelease = release?.fromVersion === version ? release : null; + const capabilities = config?.environment.capabilities; + + async function run(label: string, action: () => Promise) { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(label); + setError(null); + setNotice(null); + try { + await action(); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "The action could not be completed. Try again.", + ); + } finally { + pendingRef.current = false; + setPending(null); + } + } + + function requestServerUpdate() { + const targetVersion = checkedRelease?.targetVersion; + if (disabled || !targetVersion || !capabilities || !supportsEnvironmentUpdate(capabilities)) + return; + Alert.alert( + `Update ${environment?.environmentLabel ?? "environment"}?`, + `Install ${MOBILE_PRODUCT_NAME} ${targetVersion}. ${capabilities.serverSelfUpdate === "desktop-managed" ? "The desktop app will close and relaunch." : "The server will restart and reconnect."} Running threads may be interrupted.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Update", + onPress: () => + void run("server", async () => { + const result = await updateServer({ + environmentId, + input: { + targetVersion, + ...(capabilities.serverUpdateThreadContinuation && + config?.settings.continueThreadsAfterServerUpdate + ? { continueRunningThreads: true } + : {}), + }, + }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + setRelease(null); + setNotice(`Updated to ${result.value.targetVersion}.`); + }), + }, + ], + ); + } + + function requestProviderUpdate(provider: ServerProvider) { + if (disabled || !canUpdateEnvironmentProvider(provider)) return; + void run(provider.instanceId, async () => { + const result = await updateProvider({ + environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, + }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + }); + } + + return ( + + + {!environment ? ( + + This environment is no longer saved on this device. + + ) : ( + <> + + setConnectionExpanded((value) => !value)} + onReconnect={connections.onReconnectEnvironment} + onRemove={connections.onRemoveEnvironmentPress} + onSetEnabled={connections.onSetEnvironmentEnabled} + onUpdate={connections.onUpdateEnvironment} + /> + + {!connected ? ( + + Connect this environment to manage it. + + ) : !allowed ? ( + + {AsyncResult.isFailure(sessionResult) + ? "Could not verify your permissions. Reconnect to try again." + : session === null + ? "Checking permissions…" + : "This connection does not have permission to manage the environment."} + + ) : null} + {error ? ( + + {error} + + ) : null} + {notice ? {notice} : null} + {config ? ( + <> + + + Version {version} + {running ? ( + + {updateState.stage === "resuming" + ? "Restarting and reconnecting…" + : "Downloading update…"} + + ) : updateState.status === "failed" ? ( + + {updateState.message} + + ) : null} + {checkedRelease ? ( + + {checkedRelease.targetVersion + ? `Version ${checkedRelease.targetVersion} is available.` + : "You are up to date."} + + ) : null} + {!supportsEnvironmentUpdate(config.environment.capabilities) ? ( + + {capabilities?.serverSelfUpdate === "desktop-managed" + ? "Update the desktop app on this machine." + : `Update and restart ${MOBILE_PRODUCT_NAME} on this machine.`} + + ) : null} + + { + if (disabled) return; + void run("check", async () => { + const controller = new AbortController(); + checkController.current = controller; + const timeout = setTimeout(() => controller.abort(), 20_000); + try { + const targetVersion = await findEnvironmentUpdate( + config.environment.serverVersion, + controller.signal, + ); + setRelease({ + fromVersion: config.environment.serverVersion, + targetVersion, + }); + } finally { + clearTimeout(timeout); + checkController.current = null; + } + }); + }} + /> + {checkedRelease?.targetVersion && + supportsEnvironmentUpdate(config.environment.capabilities) ? ( + + ) : null} + + + { + if (disabled) return; + void run("refresh", async () => { + const result = await refreshProviders({ environmentId, input: {} }); + if (AsyncResult.isFailure(result)) throw squashAtomCommandFailure(result); + setNotice("Provider status refreshed."); + }); + }} + /> + {config.providers + .filter((provider) => provider.enabled) + .map((provider) => ( + + + + + + {provider.displayName ?? provider.driver} + + + + {provider.installed + ? (provider.version ?? "Version unknown") + : "Not installed"} + {provider.versionAdvisory?.latestVersion + ? ` · Latest ${provider.versionAdvisory.latestVersion}` + : ""} + + {provider.updateState && provider.updateState.status !== "idle" ? ( + + {provider.updateState.message ?? + `Update ${provider.updateState.status}`} + + ) : null} + {provider.compatibilityAdvisory?.message ? ( + + {provider.compatibilityAdvisory.message} + + ) : null} + {provider.unavailableReason || provider.message ? ( + + {provider.unavailableReason ?? provider.message} + + ) : null} + {provider.versionAdvisory?.status === "behind_latest" && + !provider.versionAdvisory.canUpdate ? ( + + Update this provider on the environment's machine. + + ) : null} + + {canUpdateEnvironmentProvider(provider) ? ( + requestProviderUpdate(provider)} + /> + ) : null} + + ))} + + + ) : null} + + )} + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 7ca791afec6d..003178a03aca 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -47,7 +47,6 @@ export function SettingsEnvironmentsRouteScreen() { const connectedCloudEnvironments = SHOWCASE_ENABLED ? SHOWCASE_CONNECTED_CLOUD_ENVIRONMENTS : environmentSections.connectedCloudEnvironments; - const [expandedId, setExpandedId] = useState(null); const headerIconColor = useUniwindTheme()["--color-icon"]; const relaySession = useAtomValue(managedRelaySessionAtom); const refreshRelayEnvironments = useAtomCommand( @@ -68,9 +67,15 @@ export function SettingsEnvironmentsRouteScreen() { } } - const handleToggle = useCallback((environmentId: EnvironmentId) => { - setExpandedId((prev) => (prev === environmentId ? null : environmentId)); - }, []); + const openEnvironment = useCallback( + (environmentId: EnvironmentId) => { + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentDetail", params: { environmentId } }, + }); + }, + [navigation], + ); const handleUpdateEnvironment = useCallback( ( environmentId: EnvironmentId, @@ -159,8 +164,9 @@ export function SettingsEnvironmentsRouteScreen() { > diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 8aff022910d0..bc5315bec132 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -1,3 +1,5 @@ +import { useNavigation } from "@react-navigation/native"; +import { SettingsRow } from "./components/SettingsRow"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; @@ -133,6 +135,7 @@ export function SettingsEnvironmentMaintenanceRouteScreen() { function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const insets = useSafeAreaInsets(); + const navigation = useNavigation(); const { selectedTargets, projectGroups, selectedProjectKey } = useSettingsEnvironmentFilter(); const selectedProject = projectGroups.find((group) => group.key === selectedProjectKey); const projectSelected = selectedProjectKey !== null; @@ -397,36 +400,61 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null} {props.page === "maintenance" ? ( - - write({ enableProviderUpdateChecks: value })} - /> - + <> + {!projectSelected ? ( + + {selectedTargets.map((target) => ( + + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsEnvironmentDetail", + params: { environmentId: target.environmentId }, + }, + }) + } + /> + ))} + + ) : null} + write({ continueThreadsAfterServerUpdate: value })} + value={uniform("enableProviderUpdateChecks")} + disabled={disabledFor("enableProviderUpdateChecks")} + onValueChange={(value) => write({ enableProviderUpdateChecks: value })} /> - - + + + write({ continueThreadsAfterServerUpdate: value }) + } + /> + + + ) : null} )} diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index bd8ec3252155..e209ab0b6ce5 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -3,17 +3,15 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollVie import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useRef, useState } from "react"; -import { Platform, Pressable, View } from "react-native"; +import { Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { AppText as Text } from "../../components/AppText"; -import { cn } from "../../lib/cn"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; @@ -182,21 +180,9 @@ function AutoSettleSettingsRows() { } /> {afterDays !== null ? ( - - - - Inactive days - + + + Inactive days - savePreferences({ legacyThreadListEnabled: value })} - /> ( ( { - const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize); - const typography = MOBILE_TYPOGRAPHY[role]; - return { - fontSize: Math.max( - 8, - Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)), - ), - lineHeight: scaledTypographyLineHeight(typography, baseFontSize), - }; - }, [appearance.baseFontSize, role]); + return useMemo( + () => resolveScaledTextRole(role, appearance.baseFontSize), + [appearance.baseFontSize, role], + ); } diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 49fb98e49976..85cb4764e25a 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -17,6 +17,7 @@ export function SettingsSwitchRow( subtitle={props.subtitle} > vi.unstubAllGlobals()); + +describe("environment maintenance access", () => { + it("requires a connected authenticated session with operate permission", () => { + const session = { + authenticated: true, + auth: { + policy: "remote-reachable" as const, + bootstrapMethods: [], + sessionMethods: [], + sessionCookieName: "session", + }, + scopes: [AuthOrchestrationOperateScope], + }; + expect(canMaintainEnvironment(session, true)).toBe(true); + expect(canMaintainEnvironment(session, false)).toBe(false); + expect(canMaintainEnvironment({ ...session, authenticated: false }, true)).toBe(false); + expect(canMaintainEnvironment({ ...session, scopes: [] }, true)).toBe(false); + const { scopes: _, ...legacy } = session; + expect(canMaintainEnvironment(legacy, true)).toBe(false); + expect(canMaintainEnvironment(null, true)).toBe(false); + }); + + it("requires remote desktop update support for desktop hosts", () => { + expect(supportsEnvironmentUpdate({})).toBe(false); + expect(supportsEnvironmentUpdate({ serverSelfUpdate: "respawn" })).toBe(true); + expect(supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed" })).toBe(false); + expect( + supportsEnvironmentUpdate({ serverSelfUpdate: "desktop-managed", desktopAppUpdate: true }), + ).toBe(true); + }); + + it("excludes unavailable, manual, busy, and incompatible provider updates", () => { + expect(canUpdateEnvironmentProvider(provider)).toBe(true); + expect(canUpdateEnvironmentProvider({ ...provider, installed: false })).toBe(false); + expect(canUpdateEnvironmentProvider({ ...provider, availability: "unavailable" })).toBe(false); + expect(canUpdateEnvironmentProvider({ ...provider, versionAdvisory: undefined })).toBe(false); + for (const latestVersionStatus of ["broken", "unsupported"] as const) { + expect( + canUpdateEnvironmentProvider({ + ...provider, + compatibilityAdvisory: { + status: "supported", + latestVersionStatus, + message: null, + recommendedVersion: null, + recommendedRange: null, + }, + }), + ).toBe(false); + } + for (const status of ["queued", "running"] as const) { + expect( + canUpdateEnvironmentProvider({ + ...provider, + updateState: { + status, + startedAt: null, + finishedAt: null, + message: null, + output: null, + }, + }), + ).toBe(false); + } + expect( + canUpdateEnvironmentProvider({ + ...provider, + versionAdvisory: { + ...provider.versionAdvisory!, + canUpdate: false, + }, + }), + ).toBe(false); + }); +}); + +describe("environment release checks", () => { + const signal = new AbortController().signal; + + it("keeps stable hosts on stable releases and ignores drafts", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + Response.json([ + { tag_name: "v2.0.0-nightly.20260923.1" }, + { tag_name: "v1.2.0", draft: true }, + { tag_name: "v1.1.0" }, + ]), + ), + ); + expect(await findEnvironmentUpdate("1.0.0", signal)).toBe("1.1.0"); + expect(await findEnvironmentUpdate("1.1.0", signal)).toBeNull(); + expect(await findEnvironmentUpdate("1.3.0", signal)).toBeNull(); + }); + + it("walks release pages to find the host's channel", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json(Array.from({ length: 100 }, () => ({ tag_name: "v2.0.0" }))), + ) + .mockResolvedValueOnce(Response.json([{ tag_name: "v1.0.0-preview.20260923.2" }])); + vi.stubGlobal("fetch", fetchMock); + expect(await findEnvironmentUpdate("1.0.0-preview.20260923.1", signal)).toBe( + "1.0.0-preview.20260923.2", + ); + expect(fetchMock.mock.calls[1]?.[0]).toContain("page=2"); + expect(fetchMock.mock.calls[1]?.[1]).toEqual({ signal }); + }); + + it("reports failed checks instead of claiming the server is current", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 403 }))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("403"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json([]))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow("No stable release"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "invalid" }))); + await expect(findEnvironmentUpdate("1.0.0", signal)).rejects.toThrow(); + }); +}); diff --git a/apps/mobile/src/features/settings/environment-maintenance.ts b/apps/mobile/src/features/settings/environment-maintenance.ts new file mode 100644 index 000000000000..f441066d7174 --- /dev/null +++ b/apps/mobile/src/features/settings/environment-maintenance.ts @@ -0,0 +1,68 @@ +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type ExecutionEnvironmentCapabilities, + type ServerProvider, +} from "@t3tools/contracts"; +import { + cliReleaseChannelOf, + cliReleaseIndexPageUrl, + newestCliReleaseVersion, +} from "@t3tools/shared/cliRelease"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; + +export function canMaintainEnvironment(session: AuthSessionState | null, connected: boolean) { + return ( + connected && + session?.authenticated === true && + session.scopes?.includes(AuthOrchestrationOperateScope) === true + ); +} + +export function supportsEnvironmentUpdate( + capabilities: Pick, +) { + return ( + capabilities.serverSelfUpdate !== undefined && + (capabilities.serverSelfUpdate !== "desktop-managed" || capabilities.desktopAppUpdate === true) + ); +} + +export function canUpdateEnvironmentProvider(provider: ServerProvider) { + const compatibility = provider.compatibilityAdvisory?.latestVersionStatus; + return ( + provider.installed && + provider.availability !== "unavailable" && + provider.versionAdvisory?.status === "behind_latest" && + provider.versionAdvisory.canUpdate && + provider.versionAdvisory.latestVersion !== null && + compatibility !== "broken" && + compatibility !== "unsupported" && + provider.updateState?.status !== "running" && + provider.updateState?.status !== "queued" + ); +} + +const Releases = Schema.Array( + Schema.Struct({ + tag_name: Schema.String, + draft: Schema.optionalKey(Schema.Boolean), + }), +); +const decodeReleases = Schema.decodeUnknownSync(Releases); + +/** Preserve the host's release channel and never offer a downgrade. */ +export async function findEnvironmentUpdate(currentVersion: string, signal: AbortSignal) { + const channel = cliReleaseChannelOf(currentVersion); + for (let page = 1; ; page++) { + const response = await fetch(cliReleaseIndexPageUrl(page), { signal }); + if (!response.ok) throw new Error(`Could not check releases (${response.status}). Try again.`); + const releases = decodeReleases(await response.json()); + const version = newestCliReleaseVersion(releases, channel); + if (version !== undefined) { + return compareSemverVersions(version, currentVersion) > 0 ? version : null; + } + if (releases.length < 100) throw new Error(`No ${channel} release was found.`); + } +} diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 557c3b190359..716f4edd4166 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -29,6 +29,7 @@ import { buildShowcasePendingTasks, SHOWCASE_PENDING_TASK_DEFINITIONS, } from "./showcasePendingTasks"; +import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; import { retryShowcaseOperation } from "./showcaseRetry"; import { clearShowcaseRenderSignal, @@ -36,6 +37,7 @@ import { isShowcaseNativeContentReady, subscribeToShowcaseRenderSignal, } from "./showcaseRenderSignal"; +import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; @@ -75,7 +77,14 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [themeRequestSettled, setThemeRequestSettled] = useState(false); const [readyScene, setReadyScene] = useState(null); const [orientationSettled, setOrientationSettled] = useState(false); + const [agentActivityStaged, setAgentActivityStaged] = useState(false); const requestedSceneRef = useRef(null); + // Staging reads the latest entities without restarting on every shell + // update, which would re-enter a permission prompt that is still open. + const entitiesRef = useRef({ threads, projects }); + useEffect(() => { + entitiesRef.current = { threads, projects }; + }, [projects, threads]); const renderSignal = useSyncExternalStore( subscribeToShowcaseRenderSignal, getShowcaseRenderSignal, @@ -109,6 +118,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) } let cancelled = false; + let lastOutcome: string | null = null; void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { isCancelled: () => cancelled, }).then((applied) => { @@ -129,6 +139,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) // A native draw belongs only to the scene request that produced it. In // particular, revisiting review must wait for its newly mounted surface. clearShowcaseRenderSignal(); + setAgentActivityStaged(false); setRequestedScene(value); }; readRequestedScene(); @@ -176,7 +187,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) }; }, [connectPairingUrl, pairingUrls]); - const scene = sceneFromPathname(props.pathname); + const routeScene = sceneFromPathname(props.pathname); + // Agent activity is captured over the thread list: the runner locks the + // simulator or opens the notification shade on top of it. + const scene = + requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene; const hasServerFixture = workspace.state.hasReadyEnvironment && workspace.environments.length >= 3 && @@ -228,6 +243,11 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) navigation.dispatch(StackActions.popToTop()); return; } + // Follows the environments scene, whose settings sheet popToTop leaves open. + if (requestedScene === "agent-activity") { + navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] })); + return; + } const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ @@ -264,6 +284,39 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) ); }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + useEffect(() => { + if (!SHOWCASE_ENABLED || scene !== "agent-activity" || !hasFixture || agentActivityStaged) { + return; + } + let cancelled = false; + let lastOutcome: string | null = null; + void retryShowcaseOperation( + async () => { + const now = Date.now(); + const { threads: latestThreads, projects: latestProjects } = entitiesRef.current; + const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); + const outcome = + activity === null + ? "fixture threads not loaded" + : await stageShowcaseAgentActivity(activity, now); + if (outcome === true) return true; + // Surfaces in the runner's Metro output when the scene never turns ready. + if (outcome !== lastOutcome) + console.warn(`[showcase] agent activity not staged: ${outcome}`); + lastOutcome = outcome; + return false; + }, + // The first attempt waits on the notification permission prompt until + // the runner answers it. + { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 }, + ).then((staged) => { + if (!cancelled && staged) setAgentActivityStaged(true); + }); + return () => { + cancelled = true; + }; + }, [agentActivityStaged, hasFixture, scene]); + useEffect(() => { if ( !SHOWCASE_ENABLED || @@ -276,6 +329,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) !orientationSettled || // Likewise for the palette: an early screenshot shows the default theme. !themeApplied || + (scene === "agent-activity" && !agentActivityStaged) || !isShowcaseNativeContentReady({ scene, themeId, renderSignal }) ) { setReadyScene(null); @@ -298,7 +352,16 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, orientationSettled, renderSignal, requestedScene, scene, themeApplied, themeId]); + }, [ + agentActivityStaged, + hasFixture, + orientationSettled, + renderSignal, + requestedScene, + scene, + themeApplied, + themeId, + ]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 11618291932d..33839b2fe473 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -2,7 +2,14 @@ import { requireOptionalNativeModule } from "expo"; import { MOBILE_THEME_IDS, type MobileThemeId } from "../../lib/mobileTheme"; -export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; +export const SHOWCASE_SCENES = [ + "threads", + "thread", + "terminal", + "review", + "environments", + "agent-activity", +] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; export type ShowcaseOrientation = "portrait" | "landscape"; diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts new file mode 100644 index 000000000000..94aaa2fc5e9f --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.test.ts @@ -0,0 +1,93 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; + +import { + buildShowcaseAgentActivity, + SHOWCASE_AGENT_ACTIVITY_ROWS, + showcaseAndroidActivityData, +} from "./showcaseAgentActivity"; + +const NOW = Date.parse("2026-07-16T09:00:00.000Z"); + +const project = (environmentId: string, id: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ProjectId.make(id), + title, + }) as EnvironmentProject; + +const thread = (environmentId: string, id: string, projectId: string, title: string) => + ({ + environmentId: EnvironmentId.make(environmentId), + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + title, + }) as EnvironmentThreadShell; + +const projects = [ + project("moonbase-terminal", "t3code", "T3 Code"), + project("suspense-station", "react", "React"), + project("kernel-cabin", "linux", "Linux"), +]; + +const threads = [ + thread("moonbase-terminal", "remote-command-center", "t3code", "Make remote coding feel local"), + thread( + "moonbase-terminal", + "pocket-command-center", + "t3code", + "Put the command center in your pocket", + ), + thread("suspense-station", "buttery-suspense", "react", "Make Suspense transitions buttery"), + thread("kernel-cabin", "beautiful-boot", "linux", "Make boot logs oddly beautiful"), +]; + +it("waits until every staged thread and its project have loaded", () => { + assert.isNull(buildShowcaseAgentActivity(threads.slice(1), projects, NOW)); + assert.isNull(buildShowcaseAgentActivity(threads, projects.slice(0, 2), NOW)); +}); + +it("stages relay-shaped rows against the seeded threads", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + + assert.strictEqual(activity.activeCount, 3); + assert.deepStrictEqual( + activity.activities.map((row) => [row.threadId, row.status, row.projectTitle, row.updatedAt]), + [ + ["pocket-command-center", "Approval", "T3 Code", "2026-07-16T08:59:00.000Z"], + ["beautiful-boot", "Input", "Linux", "2026-07-16T08:56:00.000Z"], + ["buttery-suspense", "Working", "React", "2026-07-16T08:58:00.000Z"], + ["remote-command-center", "Done", "T3 Code", "2026-07-16T08:57:00.000Z"], + ], + ); + assert.strictEqual( + activity.activities[0]?.deepLink, + "/threads/moonbase-terminal/pocket-command-center", + ); + assert.strictEqual(activity.activities.length, SHOWCASE_AGENT_ACTIVITY_ROWS.length); +}); + +it("encodes the Android Live Update and its alert like a relay push", () => { + const activity = buildShowcaseAgentActivity(threads, projects, NOW); + assert.isNotNull(activity); + if (!activity) return; + const data = showcaseAndroidActivityData(activity, NOW); + + assert.strictEqual(data.active, "true"); + assert.strictEqual(data.activity_chip, "Review"); + assert.strictEqual(data.activity_title, "3 active agents · 2 need attention"); + assert.strictEqual( + data.activity_line_0, + "Approval\tPut the command center in your pocket\tT3 Code", + ); + assert.strictEqual(data.activity_line_3, "Done\tMake remote coding feel local\tT3 Code"); + assert.strictEqual(data.alert_title, "Put the command center in your pocket"); + assert.strictEqual(data.alert_body, "Approval: T3 Code"); + assert.strictEqual(data.alert_path, "/threads/moonbase-terminal/pocket-command-center"); +}); diff --git a/apps/mobile/src/features/showcase/showcaseAgentActivity.ts b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts new file mode 100644 index 000000000000..17f25c0593bc --- /dev/null +++ b/apps/mobile/src/features/showcase/showcaseAgentActivity.ts @@ -0,0 +1,139 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; + +import type { + AgentActivityPhase, + AgentActivityProps, + AgentActivityRowProps, +} from "../../widgets/AgentActivity"; +import { MOBILE_PRODUCT_NAME } from "../../lib/mobileBranding"; + +/** + * Agent work shown by the agent-activity scene. The rows point at + * seeded showcase threads so the copy matches the thread list, but the phases + * are staged: the relay that normally derives them is not part of the capture. + * Rows follow the relay's order: attention first, then running, then finished. + */ +export const SHOWCASE_AGENT_ACTIVITY_ROWS = [ + { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, + { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, + { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, + { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, +] as const satisfies ReadonlyArray<{ + readonly threadId: string; + readonly phase: AgentActivityPhase; + readonly minutesAgo: number; +}>; + +// Matches the relay's row wording (AgentActivityPublisher.statusForPhase). +const STATUS_BY_PHASE: Record = { + starting: "Connecting", + running: "Working", + waiting_for_approval: "Approval", + waiting_for_input: "Input", + stale: "Waiting", + completed: "Done", + failed: "Failed", +}; + +const ACTIVE_PHASES: ReadonlySet = new Set([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", +]); + +/** Returns null until every staged thread and its project have loaded. */ +export function buildShowcaseAgentActivity( + threads: ReadonlyArray, + projects: ReadonlyArray, + now: number, +): AgentActivityProps | null { + const rows: AgentActivityRowProps[] = []; + for (const definition of SHOWCASE_AGENT_ACTIVITY_ROWS) { + const thread = threads.find((candidate) => String(candidate.id) === definition.threadId); + const project = thread + ? projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, + ) + : undefined; + if (!thread || !project) return null; + const environmentId = String(thread.environmentId); + rows.push({ + environmentId, + threadId: definition.threadId, + projectTitle: project.title, + threadTitle: thread.title, + modelTitle: "", + phase: definition.phase, + status: STATUS_BY_PHASE[definition.phase], + updatedAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), + deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`, + }); + } + return { + title: MOBILE_PRODUCT_NAME, + subtitle: "Agent work in progress", + activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, + updatedAt: new Date(now).toISOString(), + activities: rows, + }; +} + +/** The alert the relay sends when the hero row starts waiting on the user. */ +export function showcaseAgentAlert(activity: AgentActivityProps) { + const row = activity.activities[0]; + if (!row) return null; + return { + title: row.threadTitle, + body: `${row.status}: ${row.projectTitle}`, + path: row.deepLink, + environmentId: row.environmentId, + threadId: row.threadId, + }; +} + +/** + * The FCM data map Android's native receiver renders, mirroring the relay's + * androidActivityData so the shade shows exactly what a real push produces. + */ +export function showcaseAndroidActivityData( + activity: AgentActivityProps, + now: number, +): Record { + const attentionCount = activity.activities.filter( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ).length; + const hero = activity.activities[0]; + const alert = showcaseAgentAlert(activity); + return { + t3_kind: "agent_activity", + updated_at: String(now), + active: String(activity.activeCount > 0), + activity_chip: attentionCount > 0 ? "Review" : "Active", + activity_title: `${activity.activeCount} active agents · ${attentionCount} need attention`, + activity_phase: hero?.phase ?? "", + activity_active_count: String(activity.activeCount), + activity_attention_count: String(attentionCount), + activity_body: hero ? `${hero.status}: ${hero.threadTitle} · ${hero.projectTitle}` : "", + ...Object.fromEntries( + activity.activities.map((row, index) => [ + `activity_line_${index}`, + [row.status, row.threadTitle, row.projectTitle].join("\t"), + ]), + ), + activity_path: hero?.deepLink ?? "/", + activity_expires_at: String(now + 2 * 60 * 60_000), + ...(alert + ? { + alert_id: "showcase-alert", + alert_title: alert.title, + alert_body: alert.body, + alert_path: alert.path, + } + : {}), + }; +} diff --git a/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts new file mode 100644 index 000000000000..c4b35525c782 --- /dev/null +++ b/apps/mobile/src/features/showcase/stageShowcaseAgentActivity.ts @@ -0,0 +1,45 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { + getAgentLiveActivities, + startAgentLiveActivity, +} from "../agent-awareness/agentLiveActivity"; +import { showAndroidShowcaseAgentActivity } from "../agent-awareness/androidNotifications"; +import { showcaseAndroidActivityData } from "./showcaseAgentActivity"; + +/** + * Puts the staged agent activity on screen for the capture runner, which then + * locks the simulator (iOS) or opens the notification shade (Android). + * Resolves true once shown, otherwise the reason it could not be, so the + * caller can retry and report. + */ +export async function stageShowcaseAgentActivity( + activity: AgentActivityProps, + now: number, +): Promise { + // The runner answers the iOS prompt and pre-grants Android's, so this only + // settles the permission the runner's alert delivery depends on. + const permission = await Notifications.requestPermissionsAsync({ + ios: { allowAlert: true, allowBadge: true, allowSound: true }, + }); + if (!permission.granted) return `notification permission ${permission.status}`; + // A previous appearance's pass left its alert delivered; it would stack + // under the new one. + await Notifications.dismissAllNotificationsAsync(); + + if (Platform.OS === "android") { + return ( + showAndroidShowcaseAgentActivity(showcaseAndroidActivityData(activity, now)) || + "native showShowcaseActivity missing" + ); + } + if (Platform.OS !== "ios") return `unsupported platform ${Platform.OS}`; + + // A retried or revisited scene must not stack a second card. + await Promise.all(getAgentLiveActivities().map((existing) => existing.end("immediate"))); + // ActivityKit only starts activities while the app is foreground, which + // holds here: the runner locks the device after the scene reports ready. + return startAgentLiveActivity(activity) !== null || "Live Activity did not start"; +} diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330c..31c8497915c3 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx b/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx new file mode 100644 index 000000000000..86361fce4670 --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptSharingStatus.tsx @@ -0,0 +1,39 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, usesChatGptSharing } from "@t3tools/shared/usageLimits"; +import { Alert, Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; + +export function ChatGptSharingStatus({ provider }: { provider: ServerProvider | null }) { + if (!usesChatGptSharing(provider)) return null; + return ( + + { + Alert.alert( + "ChatGPT sharing is on", + [ + provider?.auth.email, + "Eligible usage uses your ChatGPT plan. Credit settings and limits are managed in ChatGPT.", + ] + .filter(Boolean) + .join("\n\n"), + ); + }} + > + + Using ChatGPT plan + + void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)} + > + Manage usage + + + ); +} diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx new file mode 100644 index 000000000000..d668dc50eabf --- /dev/null +++ b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx @@ -0,0 +1,40 @@ +import type { EnvironmentId, OrchestrationThreadShell } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; +import * as Option from "effect/Option"; +import { Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { useThreadDetail } from "../../state/use-thread-detail"; + +export function ChatGptUsageLimitNotice({ + environmentId, + thread, +}: { + environmentId: EnvironmentId; + thread: OrchestrationThreadShell; +}) { + const state = useThreadDetail({ environmentId, threadId: thread.id }); + const detail = Option.getOrNull(state.data); + if (!isChatGptUsageLimitError(detail?.activities ?? [], thread.session?.lastError)) return null; + return ( + + + + ChatGPT usage limit reached + + + Review your usage settings in ChatGPT to continue. + + void Linking.openURL(CHATGPT_USAGE_URL).catch(() => undefined)} + > + Manage usage + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index 7edb07f4e3d8..b30175b5b0de 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -402,7 +402,7 @@ export function NewTaskBranchPickerRouteScreen() { const branchContent = flow.filteredBranches.length === 0 ? ( ); } + const boxSize = (14 * props.size) / 16; return ( - + - + @@ -1476,13 +1486,13 @@ export function NewTaskDraftScreen(props: { accessibilityLabel={`Environment: ${selectedEnvironmentLabel}`} chevronDirection="right" disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={`on ${selectedEnvironmentLabel}`} maxWidth={260} onPress={ @@ -1517,12 +1527,13 @@ export function NewTaskDraftScreen(props: { accessibilityHint={`Switches to ${flow.workspaceMode === "local" ? "a new worktree" : "the current checkout"}`} accessibilityLabel={workspaceLabel} disabled={isComposerInteractionLocked || voiceInput.isBusy} - iconNode={ + renderIcon={(size) => ( - } + )} label={workspaceLabel} maxWidth={flow.workspaceMode === "local" ? 220 : 148} onPress={() => flow.setWorkspaceMode(flow.workspaceMode === "local" ? "worktree" : "local")} @@ -1681,12 +1692,12 @@ export function NewTaskDraftScreen(props: { accessibilityLabel="Model and reasoning settings" disabled={isComposerInteractionLocked} emphasized - iconNode={ + renderIcon={(size) => ( - } + )} label={flow.selectedModelOption?.label ?? "Choose model"} maxWidth="100%" onPress={settingsSheetPresentation.open} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index f74542076be2..27a9b3217c7e 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -333,6 +333,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps + {!voiceInput.isBusy && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( @@ -652,6 +657,27 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {selectedProviderStatus?.compatibilityAdvisory?.message && + (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || + selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( + + {selectedProviderStatus.compatibilityAdvisory.message} + + ) : null} {modelUnavailable ? ( Model unavailable. Open model settings. @@ -945,9 +971,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer - } + renderIcon={(size) => ( + + )} label={currentModelOption?.label ?? currentModelSelection.model} maxWidth="100%" onPress={openSettings} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad0872ea9116..14beefd6d178 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; @@ -78,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingApproval, PendingUserInput, @@ -353,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncLabel = (() => { + const realThreadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": @@ -366,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); + // Opening a running thread resyncs for a few frames. The pill shows the + // sync label only when the sync lasts, so it does not flash before the timer. + const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); // One floating pill above the composer: it reads the connection phase while // disconnected, the sync state while messages load, then the working timer // once the feed is settled. @@ -674,10 +679,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const workspaceContentWidth = useWorkspaceContentWidth(); + // Clearing animated width can retain the unfolded width after Android resumes folded. + // Assign both layouts explicitly so the dock always follows its current parent. const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null - ? { width: workspaceContentWidth.value, right: undefined } - : { width: undefined, right: 0 }, + ? { width: workspaceContentWidth.value } + : { width: "100%" }, ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); @@ -892,39 +899,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : "absolute inset-0 bg-screen" } /> - + resetKeys={[props.threadCwd]} + renderFallback={(fallback) => ( + + )} + > + + ) : ( @@ -947,7 +966,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 20848cd2ad10..83b0bef9c022 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2477,7 +2477,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // content-inset override. Seed the fresh instance synchronously with the // current overlay height before the scroll integration's next reaction; // on Android the declarative contentInset floor covers this same window. - const listMountKey = `${feedThreadKey}:${presentedFeed.length === 0 ? "empty" : "filled"}`; + // The thinking row a running thread shows while its messages load is not + // content: the list must still remount, and so open at the end, when they + // arrive. + const listMountKey = `${feedThreadKey}:${presentedFeed.some((entry) => entry.type !== "thinking") ? "filled" : "empty"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index bf957ad54195..d9e165bd0107 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,5 +1,6 @@ +import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { createThreadMovePlanner } from "./threadOrder"; +import { computeThreadMoveAvailability } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -29,7 +30,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; -import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -39,24 +39,9 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, - useHomeListOptions, -} from "../home/home-list-options"; +import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "../home/homeListItems"; -import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; +import { buildHomeProjectScopes } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -66,17 +51,11 @@ import { } from "../home/WorkspaceConnectionTitle"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar"; -import { useMaterialToolbarHeight } from "../../components/useMaterialToolbarHeight"; +import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout"; import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "./thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -84,21 +63,21 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; -import { resolveThreadProviderInstance } from "./thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + isThreadListV2ListItem, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "./threadListV2"; -/** The sidebar list serves both lists: v1 grouped items or, when the Thread - List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled - "Show more" pager. */ +/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a + settled "Show more" pager row. */ type SidebarListItem = - | HomeListItem | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; @@ -155,6 +134,7 @@ function ThreadNavigationSidebarPane( const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); + const { fabClearance } = useAndroidControlSizing(); const projects = useProjects(); const threads = useThreadShells(); const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState(); @@ -172,11 +152,11 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, + setThreadAutoSettle, moveThread, renameThread, regenerateThreadTitle, } = useThreadListActions(); - const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const queuedThreadKeys = useQueuedThreadKeys(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -194,8 +174,7 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = - useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); const searchEnvironmentIds = useMemo( () => options.selectedEnvironmentId === null @@ -284,93 +263,6 @@ function ThreadNavigationSidebarPane( ), [selectedProjectScope], ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? projects - : projects.filter((project) => - selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, projects, selectedProjectRefs], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, selectedProjectRefs, threads], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? pendingTasks - : pendingTasks.filter((pendingTask) => - selectedProjectRefs.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, pendingTasks, selectedProjectRefs], - ); - const groups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: options.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - matchedThreadKeys, - options, - props.searchQuery, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); - return next; - }); - }, []); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], - ); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -410,12 +302,11 @@ function ThreadNavigationSidebarPane( // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { - if (!threadListV2Enabled) return; // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [threadListV2Enabled]); + }, []); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). const serverConfigs = useAtomValue(environmentServerConfigsAtom); @@ -446,6 +337,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const autoSettleOptOutEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadAutoSettleOptOut === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const pinReorderEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -483,12 +383,19 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -508,7 +415,7 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, threads, @@ -520,16 +427,6 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; return buildThreadListV2Items({ pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -560,7 +457,6 @@ function ThreadNavigationSidebarPane( settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Enabled, threads, selectedProjectScope, ]); @@ -579,7 +475,6 @@ function ThreadNavigationSidebarPane( // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { - if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks are not thread shells, so the v2 item builder // never sees them; the shared splice puts them below the active block // (mirrors the compact Home v2 list) where they stay visible and @@ -607,6 +502,10 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }); if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ @@ -617,15 +516,17 @@ function ThreadNavigationSidebarPane( } return items; }, [ - listLayout.items, nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, + queuedThreadKeys, + threadMoveAvailability, selectedProjectRefs, settledShelfExpanded, + shelfPreferencesLoaded, snoozedShelfExpanded, - threadListV2Enabled, + snoozeEnvironmentIds, threadListV2Layout, ]); const listMenuActions = useMemo( @@ -671,33 +572,8 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - // v2 lays the list out in fixed creation order — offering sort/group - // controls it silently ignores would be a lie. Environment still - // scopes the v2 partition, so it stays. - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [environments, options, projectFilterOptions, selectedProjectKey], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -724,39 +600,19 @@ function ThreadNavigationSidebarPane( } return; } - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => `project-sort:${option.value}` === event, - ); - if (projectSort) { - setProjectSortOrder(projectSort.value); - return; - } - const threadSort = THREAD_SORT_OPTIONS.find( - (option) => `thread-sort:${option.value}` === event, - ); - if (threadSort) { - setThreadSortOrder(threadSort.value); - return; - } }, - [ - environments, - projectFilterOptions, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - ], + [environments, projectFilterOptions, setSelectedEnvironmentId], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); - const materialToolbarHeight = useMaterialToolbarHeight(); + const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout(); // The sticky header (title row, search field, optional connection status) // is measured so the list inset always matches its real height — no // hardcoded per-variant constants. const stickyHeaderHeight = measuredHeaderHeight ?? (Platform.OS === "android" - ? Math.max(insets.top, 12) + materialToolbarHeight + 8 + ? paddingTop + height + paddingBottom : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT); const topListInset = stickyHeaderHeight + 6; const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => { @@ -789,9 +645,11 @@ function ThreadNavigationSidebarPane( onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - // Project shells load after the first rows draw, so the maps they feed have - // to bust the recycler's memoization — otherwise a row keeps the blank - // favicon and fallback title it was first rendered with. + // The sticky header's project shells and search maps feed row props, so + // they have to bust the recycler's memoization — otherwise a row keeps the + // blank favicon and fallback title it was first rendered with. The minute + // clock deliberately stays out: its per-row text lives on the items, so a + // tick only re-renders rows whose displayed text actually moved. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", @@ -799,7 +657,6 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -808,53 +665,19 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, ], ); useThreadJumpShortcuts(listItems, handleSelectThread); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { - if (previous.type === "v2-thread" && item.type === "v2-thread") { - return ( - previous.key === item.key && - previous.item.thread === item.item.thread && - previous.item.variant === item.item.variant && - previous.item.snoozed === item.item.snoozed && - previous.item.pinned === item.item.pinned && - previous.snoozeWakeLabelText === item.snoozeWakeLabelText - ); + if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) { + return threadListV2ListItemsAreEqual(previous, item); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } - if (previous.type === "v2-pending" && item.type === "v2-pending") { - return ( - previous.pendingTask === item.pendingTask && - previous.showPendingDivider === item.showPendingDivider - ); - } - if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if ( - previous.type === "v2-thread" || - previous.type === "v2-show-more" || - previous.type === "v2-pending" || - previous.type === "v2-snoozed-shelf" || - previous.type === "v2-settled-shelf" || - item.type === "v2-thread" || - item.type === "v2-show-more" || - item.type === "v2-pending" || - item.type === "v2-snoozed-shelf" || - item.type === "v2-settled-shelf" - ) { - return false; - } - return homeListItemsAreEqual(previous, item); + return false; }, [], ); @@ -904,24 +727,29 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; - const movePlanner = item.item.pinned - ? threadMovePlanners.pinned - : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + // Intentional difference from Home: the sidebar never passes + // `showTrailingDivider` because its rows render no Home-style row + // hairline at all — card rows carry tonal containers in this pane + // (the hairline branch is !sidebarPane-only) and slim rows have no + // hairline branch. The stamp still rides the shared list items + // because Home's boundary suppression consumes it; the sidebar's + // only cost is the occasional divider-only equality invalidation, + // which re-renders identically. return ( 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -950,18 +778,20 @@ function ThreadNavigationSidebarPane( onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)} pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} reorderSupported={ item.item.pinned ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} + onSetThreadAutoSettle={setThreadAutoSettle} onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -973,7 +803,7 @@ function ThreadNavigationSidebarPane( return ( ); - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); } }, [ archiveThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, @@ -1094,19 +843,19 @@ function ThreadNavigationSidebarPane( pinReorderEnvironmentIds, pinThread, pinningEnvironmentIds, + autoSettleOptOutEnvironmentIds, + setThreadAutoSettle, projectByKey, projectTitleByProjectKey, regenerateThreadTitle, renameThread, + threadSearchMatchByKey, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, - threadSearchMatchByKey, titleRegenerationEnvironmentIds, settleThread, settlementEnvironmentIds, @@ -1114,20 +863,17 @@ function ThreadNavigationSidebarPane( sidebarScrollGesture, snoozeEnvironmentIds, snoozeThread, - nowMinute, + resolveProviderInstance, toggleSettledShelf, toggleSnoozedShelf, unpinThread, unsettleThread, unsnoozeThread, - updateGroupDisplay, ], ); - // v2 ignores the sort/group options, so only the environment filter can - // light the "customized" state while the beta is on. - const filterCustomized = threadListV2Enabled - ? options.selectedEnvironmentId !== null || selectedProjectKey !== null - : hasCustomHomeListOptions({ ...options, selectedProjectKey }); + // The list ignores sort/group options, so only the environment and project + // filters can light the "customized" state. + const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null; const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; @@ -1138,24 +884,10 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, selectedProjectKey, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, - onProjectSortOrderChange: setProjectSortOrder, - onThreadSortOrderChange: setThreadSortOrder, - listOrganization: !threadListV2Enabled, }), - [ - environments, - options, - projectFilterOptions, - selectedProjectKey, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - threadListV2Enabled, - ], + [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], ); const nativeHeaderItems = useMemo( () => @@ -1309,7 +1041,7 @@ function ThreadNavigationSidebarPane( { paddingBottom: Platform.OS === "android" - ? Math.max(insets.bottom, 16) + 148 - insets.bottom + ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom : 16 + insets.bottom, paddingTop: Platform.OS === "android" ? 6 : topListInset, }, @@ -1365,10 +1097,7 @@ function ThreadNavigationSidebarPane( /> - + diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e5bfc1590cf0..4fb22d152496 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -606,10 +606,19 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + selectedThreadCwd, + ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, @@ -947,18 +956,7 @@ function ThreadRouteContent( <> - + {content} @@ -185,9 +185,8 @@ function DisclosureRow(props: { @@ -685,6 +684,12 @@ function ThreadSettingsOptionsItem(props: { }) { const insets = useSafeAreaInsets(); const session = useThreadSettingsSession(); + const configs = useAtomValue(environmentServerConfigsAtom); + const selectedProvider = session.environmentId + ? (configs + .get(session.environmentId) + ?.providers.find((provider) => provider.instanceId === session.providerInstanceId) ?? null) + : null; const bottomToolbarInset = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET @@ -692,6 +697,7 @@ function ThreadSettingsOptionsItem(props: { return ( + Options flow.setSelectedModelKey(option.key, option.selection.options)} diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 7ca5085965f5..d16b72d13d81 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -63,7 +63,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( {Platform.OS === "android" ? ( @@ -83,31 +83,19 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - - + + New branch - - + + New worktree {Platform.OS === "android" ? ( @@ -157,7 +133,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBaseBranch} placeholder="main" accessibilityLabel="Worktree base branch" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> {Platform.OS === "android" ? ( New branch @@ -167,7 +143,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" accessibilityLabel="Worktree branch name" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> - + Existing branches {branchesLoading ? ( - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -236,16 +196,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - + {branch.name} {subtitle} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7f70a39d9372..19d77a327def 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -72,7 +72,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( {Platform.OS === "android" ? ( @@ -92,35 +92,21 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - - + + Branch - + {gitStatus.data?.refName ?? "(detached HEAD)"} @@ -131,20 +117,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + - + Files @@ -154,39 +130,19 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {!allSelected && isEditingFiles ? ( setExcludedFiles(new Set())} > - + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -226,15 +182,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { - - + + Commit message - - + + void runCommitAction(true)} /> - + {Platform.OS === "android" ? ( @@ -130,49 +130,31 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {Platform.OS !== "android" ? ( Confirm ) : null} - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} {sheetMenuItems.map(({ item, disabledReason }, index) => ( @@ -320,11 +316,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {linkedPrChains.map((chain) => ( {chain.layers.length > 1 ? ( @@ -483,13 +475,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {isInspector ? ( - + {Platform.OS === "android" ? ( {refreshMenu} ) : ( diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index daf494cb79cc..c358466e22d2 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -71,13 +71,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -97,21 +91,11 @@ export function SheetListRow(props: { }) { return ( - + - + {props.title} {props.subtitle ? ( diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 3c81d8231216..f48ba92396e9 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskBranchLabel, resolveNewTaskLocalWorkspaceSelection, @@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => { ).toBe("Choose branch"); }); }); + +describe("filterNewTaskBranches", () => { + const branches = [ + { name: "main", isRemote: false }, + { name: "Feature/Login-Page", isRemote: false }, + { name: "origin/fix/remote-only", isRemote: true }, + ]; + const search = (query: string) => + filterNewTaskBranches(branches, query).map((branch) => branch.name); + + it("ignores case in both the query and the branch name", () => { + expect(search("feature/login")).toEqual(["Feature/Login-Page"]); + expect(search("MAIN")).toEqual(["main"]); + }); + + it("keeps remote-only branches searchable", () => { + expect(search("remote-only")).toEqual(["origin/fix/remote-only"]); + }); + + it("matches a typed space against the dash a branch name uses", () => { + expect(search(" login page ")).toEqual(["Feature/Login-Page"]); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 99eee3ea48ae..87c9b2595f4f 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,3 +1,5 @@ +import { sanitizeNewRefName } from "@t3tools/shared/git"; + type WorkspaceMode = "local" | "worktree"; export function resolveNewTaskWorkspaceLabel(input: { @@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: { }): boolean { return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; } + +export function filterNewTaskBranches( + branches: ReadonlyArray, + rawQuery: string, +): ReadonlyArray { + const query = sanitizeNewRefName(rawQuery).toLowerCase(); + return query.length === 0 + ? branches + : branches.filter((branch) => branch.name.toLowerCase().includes(query)); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index f2801255b3bc..61b77afffba1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -18,6 +18,7 @@ import { T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { sanitizeNewRefName } from "@t3tools/shared/git"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; @@ -89,9 +90,10 @@ import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; import { resolvePendingTaskInteractionMode, resolveProviderInteractionMode, -} from "./legacy-plan-mode"; +} from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { + filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; @@ -129,6 +131,9 @@ export function branchBadgeLabel(input: { if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { return "worktree"; } + if (input.branch.isRemote) { + return "remote"; + } if (input.branch.isDefault) { return "default"; } @@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } replaceComposerDraftAttachments(selectedProjectDraftKey, []); }, [selectedProjectDraftKey]); - const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); + const branchSearchQuery = sanitizeNewRefName(branchQuery); + const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS); const branchTarget = useMemo( () => ({ environmentId: selectedProject?.environmentId ?? null, @@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], ); const branchState = usePaginatedBranches(branchTarget); - const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); + const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery; const branchesLoading = branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); const branchesFetchingNextPage = branchState.isFetchingNextPage; @@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; - const filteredBranches = useMemo(() => { - const query = branchQuery.trim().toLowerCase(); - if (query.length === 0) { - return availableBranches; - } - - return pipe( - availableBranches, - Arr.filter((branch) => branch.name.toLowerCase().includes(query)), - ); - }, [availableBranches, branchQuery]); + const filteredBranches = useMemo( + () => filterNewTaskBranches(allBranchRefs, branchQuery), + [allBranchRefs, branchQuery], + ); // The composer's draft follows the project it will be sent to: switching // mid-compose keeps the same draft and moves it, so typed text follows the diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057d..1f9297f47258 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { withNativeGlassHeaderItem({ type: "menu", label: "", - accessibilityLabel: "Filter and sort threads", + accessibilityLabel: "Filter threads", icon: sfSymbolIcon(input.filterIcon), menu: { title: input.filterMenu.title, diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9b41ed4e362a..5a1601b0e5ae 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { View } from "react-native"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; + export type ThreadInspectorMode = "route" | "git" | "files"; const INSPECTOR_PREWARM_DELAY_MS = 350; @@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -27,7 +30,14 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } @@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: { @@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx deleted file mode 100644 index 04d30a26a901..000000000000 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ /dev/null @@ -1,849 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; -import { SymbolView } from "../../components/AppSymbol"; -import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Platform, Pressable, useWindowDimensions, View } from "react-native"; -import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import Svg, { Circle, Path } from "react-native-svg"; - -import { RowPressable } from "../../components/RowPressable"; -import { AppText as Text } from "../../components/AppText"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; -import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { relativeTime } from "../../lib/time"; -import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; -import type { HomeGroupDisplayAction } from "../home/homeListItems"; -import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; -import { QueuedMessageIcon } from "./queued-message-icon"; -import { resolveThreadStatus } from "./threadPresentation"; -import { ThreadSearchMatchExcerpt } from "./thread-search-match"; - -/** - * Shared presentation for the thread lists: the compact (phone) Home list and - * the iPad sidebar render the SAME items — group headers with collapse, - * thread rows with status/PR/subtitle, and show-more rows — differing only in - * metrics and chrome via `variant`. - */ -export type ThreadListVariant = "compact" | "sidebar"; - -/** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; -const SIDEBAR_ROW_RADIUS = 12; - -function pullRequestTintColor( - pr: Pick, - colorScheme: "light" | "dark", -) { - const dark = colorScheme === "dark"; - if (pr.state === "open" && pr.isDraft === true) { - return dark ? "#a1a1aa" : "#71717a"; - } - switch (pr.state) { - case "open": - return dark ? "#34d399" : "#059669"; - case "merged": - return dark ? "#a78bfa" : "#7c3aed"; - case "closed": - if (pr.kind === "stack" || pr.others > 0) { - return dark ? "#fb7185" : "#e11d48"; - } - return dark ? "#a1a1aa" : "#71717a"; - case null: - return dark ? "#a1a1aa" : "#71717a"; - } -} - -function PullRequestIcon(props: { readonly size: number; readonly color: string }) { - return ( - - - - - - - ); -} - -/* ─── Project group header ───────────────────────────────────────────── */ - -export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { - readonly variant: ThreadListVariant; - readonly project: EnvironmentProject; - readonly title: string; - readonly threadCount: number; - readonly collapsed: boolean; - readonly isFirst: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; - /** Project a quick new thread should target; null hides the button. */ - readonly newThreadTarget?: EnvironmentProject | null; - readonly onNewThread?: (project: EnvironmentProject) => void; -}) { - const { groupKey, onGroupAction, onNewThread } = props; - const newThreadTarget = props.newThreadTarget ?? null; - const compact = props.variant === "compact"; - const handleToggle = useCallback( - () => onGroupAction(groupKey, "toggle-collapsed"), - [groupKey, onGroupAction], - ); - const handleNewThread = useCallback(() => { - if (newThreadTarget) { - onNewThread?.(newThreadTarget); - } - }, [newThreadTarget, onNewThread]); - const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; - - // The new-thread button is a SIBLING of the collapse toggle, not a child: - // nested touchables are unreachable to VoiceOver/TalkBack (the parent - // swallows focus). Row padding lives on the container (explicit styles — - // dynamic padding classes on Pressable did not apply reliably) so both - // children share one centerline; hitSlop restores the padded tap area. - const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; - return ( - - - - - {props.title} - - - {props.threadCount} - - - {showNewThreadButton ? ( - ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} - > - - - ) : null} - - ); -}); - -/* ─── Show more / show less row ──────────────────────────────────────── */ - -export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { - readonly variant: ThreadListVariant; - readonly hiddenCount: number; - readonly canShowLess: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; -}) { - const showsMore = props.hiddenCount > 0; - const compact = props.variant === "compact"; - const { groupKey, onGroupAction } = props; - const handleShowMore = useCallback( - () => onGroupAction(groupKey, "show-more"), - [groupKey, onGroupAction], - ); - const handleShowLess = useCallback( - () => onGroupAction(groupKey, "show-less"), - [groupKey, onGroupAction], - ); - - const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( - ({ - opacity: pressed ? 0.6 : 1, - paddingHorizontal: compact ? 14 : 12, - paddingVertical: compact ? 7 : 6, - borderCurve: "continuous", - })} - > - - - - {label} - - - - ); - - return ( - - {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} - {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} - - ); -}); - -/* ─── Pending task row ───────────────────────────────────────────────── */ - -const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, -]; - -/** - * Unsent work: a task queued in the outbox for its environment to reconnect, - * or a draft still sitting in the project's new-task composer. Tapping - * reopens the composer with everything prefilled; the row disappears once - * the work is sent and the real thread arrives. The two kinds differ in what - * happens next, so the pill and icon say which one this is: a queued task - * sends itself, a draft waits for the user. - */ -export const PendingTaskListRow = memo(function PendingTaskListRow(props: { - readonly variant: ThreadListVariant; - readonly pendingTask: PendingNewTask; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - readonly isLast: boolean; - readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; - readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; -}) { - const compact = props.variant === "compact"; - - const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const isDraft = pendingTask.kind === "draft"; - const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); - // The pill only has room for one word, so what happens next goes in the - // subtitle: a queued task sends itself, a draft waits for the user. - const subtitleParts = [ - isDraft ? null : "Sends on reconnect", - props.environmentLabel, - pendingTask.branch, - ].filter((part): part is string => Boolean(part)); - - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); - }, - [onDeletePendingTask, pendingTask], - ); - - const statusPill = isDraft ? ( - - Draft - - ) : ( - - - Pending - - - ); - - const subtitleRow = - subtitleParts.length > 0 ? ( - - - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null; - - const accessibilityHint = isDraft - ? "Opens the draft in the new task composer" - : "Sends when the environment reconnects. Opens the task for editing"; - - const rowContent = compact ? ( - onSelectPendingTask(pendingTask)} - > - - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - - {subtitleRow} - - - - ) : ( - onSelectPendingTask(pendingTask)} - style={{ - borderRadius: SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - {subtitleRow} - - - ); - - return ( - - {rowContent} - - ); -}); - -/* ─── Thread row ─────────────────────────────────────────────────────── */ - -const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ - { id: "archive", title: "Archive", image: "archivebox" }, - { id: "rename", title: "Rename", image: "square.and.pencil" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -export const ThreadListRow = memo(function ThreadListRow(props: { - readonly variant: ThreadListVariant; - readonly thread: EnvironmentThreadShell; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - /** A message for this thread is waiting in the outbox. */ - readonly hasQueuedMessages?: boolean; - readonly searchMatch?: EnvironmentThreadSearchMatch; - readonly searchQuery?: string; - readonly isLast: boolean; - /** Sidebar only: the thread currently open in the detail pane. */ - readonly selected?: boolean; - /** Defaults to window width minus compact margins. */ - readonly fullSwipeWidth?: number; - readonly onSelectThread: (thread: EnvironmentThreadShell) => void; - readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; - readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; - readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; - readonly onRenameThread: (thread: EnvironmentThreadShell) => void; - readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; - readonly titleRegenerationSupported: boolean; - readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; - readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly simultaneousSwipeGesture?: ComponentProps< - typeof ThreadSwipeable - >["simultaneousWithExternalGesture"]; -}) { - const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); - const compact = props.variant === "compact"; - const selected = props.selected === true; - const visuallySelected = selected && (!compact || Platform.OS === "android"); - const theme = useUniwindTheme(); - const screenColor = theme["--color-screen"]; - const drawerColor = theme["--color-drawer"]; - const selectedBackgroundColor = theme["--color-thread-selected"]; - - const { - thread, - onSelectThread, - onArchiveThread, - onDeleteThread, - onRenameThread, - onRegenerateThreadTitle, - onNewThreadOnBranch, - } = props; - const status = resolveThreadStatus(thread); - const pr = useThreadPr(thread); - const timestamp = relativeTime( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - ); - const threadAccessibilityLabel = [ - thread.title, - pr?.accessibilityLabel, - props.hasQueuedMessages ? "messages queued to send" : null, - ] - .filter(Boolean) - .join(", "); - const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => - Boolean(part), - ); - - const backgroundColor = compact ? screenColor : drawerColor; - - const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); - const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); - const handleRegenerateTitle = useCallback( - () => onRegenerateThreadTitle(thread), - [onRegenerateThreadTitle, thread], - ); - const menuActions = useMemo( - () => [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - title: - Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, - image: "square.and.pencil", - }, - ] - : []), - THREAD_ROW_MENU_ACTIONS[0]!, - THREAD_ROW_MENU_ACTIONS[1]!, - { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...buildThreadTitleRegenerationMenuItems({ - supported: props.titleRegenerationSupported, - isRegenerating: thread.titleRegeneration != null, - }), - THREAD_ROW_MENU_ACTIONS[2]!, - ], - [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], - ); - const primaryAction = useMemo( - () => ({ - accessibilityLabel: `Archive ${thread.title}`, - icon: "archivebox" as const, - label: "Archive", - onPress: handleArchive, - }), - [handleArchive, thread.title], - ); - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); - if (nativeEvent.event === "archive") handleArchive(); - if (nativeEvent.event === "rename") handleRename(); - if (nativeEvent.event === "copy-thread-id") { - copyTextWithHaptic(thread.id, { target: "thread-id" }); - } - if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); - if (nativeEvent.event === "delete") handleDelete(); - }, - [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], - ); - - const statusPill = status ? ( - - {status.label} - - ) : null; - - const subtitleRow = - subtitleParts.length > 0 || pr !== null ? ( - - {subtitleParts.length > 0 ? ( - <> - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null} - {pr !== null ? ( - - {pr.kind === "stack" ? ( - - ) : ( - - )} - - {pr.label} - - - ) : null} - - ) : null; - - const rowContent = (close: () => void) => - compact ? ( - { - close(); - onSelectThread(thread); - }} - > - - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - - ) : ( - { - close(); - onSelectThread(thread); - }} - style={{ - backgroundColor: visuallySelected ? selectedBackgroundColor : backgroundColor, - borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - ); - - return ( - - {(close) => ( - // Messages-style row actions on long-press. iOS: a real - // UIContextMenuInteraction with the row as the zoom preview (needs the - // patched @react-native-menu, see - // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the - // interaction is hosted by the component view and the underlying - // UIButton passes touches through, so row taps keep working). Android: - // ControlPillMenu injects onLongPress into the row and anchors the - // token-styled dropdown to it; taps and swipes are untouched. - - {rowContent(close)} - - )} - - ); -}); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 797781d16a4c..09ffe404ee79 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -18,7 +18,6 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Pressable, useWindowDimensions, View } from "react-native"; @@ -33,10 +32,10 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { @@ -69,10 +68,6 @@ const STATUS_LABEL_BY_STATUS: Partial< failed: { label: "Failed", className: "text-danger-foreground" }, }; -function threadTimeLabel(thread: EnvironmentThreadShell): string { - return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); -} - // Menus keep lifecycle and title regeneration together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ @@ -300,8 +295,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ) : null} @@ -453,8 +449,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; - /** Parent minute tick passed as a prop so this memoized row refreshes its - native snooze menu while mounted. */ + /** Preformatted against the parent clock (row order timestamp: settle stamp + on settled rows, latest activity otherwise). Blank while a status label + or the wake countdown owns that slot. Precomputed per row — not via the + list's extraData — so the minute tick re-renders only rows whose + displayed text moved. */ + readonly timeLabel: string; + /** Parent minute tick carried on the row's list item, present only when the + row's menu offers snooze presets, so those menus refresh while mounted + without invalidating every other row. */ readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; @@ -492,6 +495,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => void; readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; + readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; @@ -499,6 +503,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly snoozeSupported: boolean; /** False on servers that predate thread.pin/unpin. */ readonly pinningSupported: boolean; + /** False on servers that predate thread.auto-settle.set. */ + readonly autoSettleOptOutSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; /** Server supports reordering this card's section. */ @@ -513,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** List key checked against the Home swipe row activation. */ + readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -535,10 +543,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, + onSetThreadAutoSettle, onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const dormant = useSwipeRowDormant(props.activationKey); const pr = useThreadPr(thread); @@ -549,13 +559,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; - // Settled rows label by the same stamp they sort by, so order and label - // can't disagree. updatedAt is always present, so the resolver never - // returns null here. - const settledTimestamp = - variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; - const timeLabel = - settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); + // The timestamp is precomputed on the list item (same stamps the settled + // tail sorts by) so a minute tick only re-renders rows that draw it. + const timeLabel = props.timeLabel; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); @@ -565,6 +571,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); + // A recycled cell reassigns this mounted row to a different thread without + // remounting it, and the render closure stops running while list equality + // says the item is unchanged — so any row-local UI state must be dismissed + // when the identity under it changes. Without this, a custom snooze sheet + // opened for one thread survives the thread's removal/reorder and its + // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable + // enforces the same contract on the swipe layer with its resetKey.) + const rowIdentity = `${thread.environmentId}:${thread.id}`; + const [boundIdentity, setBoundIdentity] = useState(rowIdentity); + if (boundIdentity !== rowIdentity) { + setBoundIdentity(rowIdentity); + setCustomSnoozeOpen(false); + } const handleSnooze = useCallback( (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), [onSnoozeThread, thread], @@ -573,6 +592,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); + const handleSetAutoSettle = useCallback( + (enabled: boolean) => onSetThreadAutoSettle(thread, enabled), + [onSetThreadAutoSettle, thread], + ); const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); @@ -651,6 +674,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); + // A submenu with the current option checked, matching web. This is a + // per-thread setting, not a lifecycle verb. + const autoSettleMenuItems = useMemo( + () => + props.autoSettleOptOutSupported + ? [ + { + id: "auto-settle", + title: "Auto-settle behavior", + image: "timer", + subactions: [ + { + id: "auto-settle:enabled", + title: "Enabled", + state: thread.autoSettleDisabledAt == null ? "on" : "off", + }, + { + id: "auto-settle:disabled", + title: "Disabled", + state: thread.autoSettleDisabledAt == null ? "off" : "on", + }, + ], + } satisfies MenuAction, + ] + : [], + [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], + ); const titleMenuItems = useMemo( () => [ { id: "rename", title: "Rename", image: "square.and.pencil" }, @@ -672,19 +722,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); + // Settled and snoozed rows keep the setting too, matching web where every + // row shares one menu builder. const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, @@ -692,13 +746,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (action) => action.id !== "move-up" && action.id !== "move-down", ), ...titleMenuItems, + ...autoSettleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleMenuItems], + () => [ + SNOOZED_MENU_ACTIONS[0]!, + ...titleMenuItems, + ...autoSettleMenuItems, + SNOOZED_MENU_ACTIONS[1]!, + ], + [autoSettleMenuItems, titleMenuItems], ); const legacyMenuActions = useMemo( () => [ @@ -717,6 +777,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); + if (nativeEvent.event === "auto-settle:enabled") handleSetAutoSettle(true); + if (nativeEvent.event === "auto-settle:disabled") handleSetAutoSettle(false); if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true); if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); @@ -754,10 +816,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handlePin, handleSettle, handleSnooze, + handleSetAutoSettle, handleUnpin, handleUnsettle, handleUnsnooze, snoozePresets, + setCustomSnoozeOpen, ], ); const primaryAction = useMemo(() => { @@ -831,8 +895,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} @@ -963,27 +1028,25 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} {pr ? ( - {pr.kind === "stack" || pr.others > 0 ? ( - - ) : null} + - {pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`} + {pr.label} ) : null} @@ -1065,8 +1128,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -1118,6 +1182,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} { expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); }); }); + +describe("createThreadRowProviderInstanceResolver", () => { + const environmentId = EnvironmentId.make("environment-a"); + const serverConfigs = new Map([ + [ + environmentId, + makeConfig([ + { instanceId: "codex", driver: "codex", displayName: "Codex" }, + { instanceId: "codex_work", driver: "codex", displayName: "Codex" }, + ]), + ], + ]); + + it("hands out the same reference for repeated lookups of one instance", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const first = resolve(makeThread(environmentId, "codex")); + const second = resolve(makeThread(environmentId, "codex")); + // Memoized rows compare props by reference: a fresh object per call would + // re-render every row on every parent render (minute tick included). + expect(first).not.toBeNull(); + expect(second).toBe(first); + }); + + it("distinguishes instances of the same driver", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const personal = resolve(makeThread(environmentId, "codex")); + const work = resolve(makeThread(environmentId, "codex_work")); + expect(personal).not.toBeNull(); + expect(work).not.toBeNull(); + expect(work).not.toBe(personal); + expect(work?.displayName).toBe("Codex Work"); + }); + + it("hands out a new identity when the server-config generation changes", () => { + const before = createThreadRowProviderInstanceResolver(serverConfigs); + const nextConfigs = new Map([ + [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], + ]); + const after = createThreadRowProviderInstanceResolver(nextConfigs); + expect(after(makeThread(environmentId, "codex"))).not.toBe( + before(makeThread(environmentId, "codex")), + ); + }); + + it("resolves unknown instances to null without throwing", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 29dbe8828b66..cd07f636820c 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -1,3 +1,5 @@ +import { useMemo } from "react"; + import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { normalizeProviderAccentColor, @@ -40,3 +42,33 @@ export function resolveThreadProviderInstance( ), }; } + +/** + * Builds a resolver handing out reference-stable `ThreadRowProviderInstance` + * objects. `resolveThreadProviderInstance` builds a fresh object per call, + * which breaks the memoized row's props comparison on every parent render — + * the result only depends on (environment, instance id), so one cache per + * server-config generation keeps each row's `providerInstance` prop stable + * until the instance behind the row actually changes. + */ +export function createThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + const cache = new Map(); + return (thread) => { + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) return cached; + const resolved = resolveThreadProviderInstance(serverConfigs, thread); + cache.set(cacheKey, resolved); + return resolved; + }; +} + +/** List-scoped wrapper: one cache per server-config generation. */ +export function useThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]); +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index afc00ef9ab40..4bbcf2233a42 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -6,6 +6,7 @@ import { threadDropLifecycle, reconcilePendingThreadOrder, type PendingThreadOrder, + type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -19,7 +20,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; @@ -27,12 +28,14 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, - resolveThreadListV2Enabled, + isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2, + threadListV2ListItemsAreEqual, + type ThreadListV2ListItem, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); @@ -120,29 +123,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { }); }); -describe("resolveThreadListV2Enabled", () => { - it("defaults on when the device has never chosen", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), - ).toBe(true); - }); - - it("honors an explicit legacy opt-in", () => { - expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( - false, - ); - expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( - true, - ); - }); - - it("holds the default while preferences are still loading so the list does not remount", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), - ).toBe(true); - }); -}); - describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ @@ -1482,3 +1462,551 @@ describe("cross-section thread drops", () => { ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); }); + +/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */ + +const BASE_MS = Date.parse(NOW); +const isoAt = (ms: number) => new Date(ms).toISOString(); +const MINUTE_MS = 60_000; + +function runningSession(threadId: string) { + return { + threadId: ThreadId.make(threadId), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +function buildTickThreads() { + return { + ready: makeThread({ + id: ThreadId.make("tick-ready"), + title: "tick ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + approval: makeThread({ + id: ThreadId.make("tick-approval"), + title: "tick approval", + hasPendingApprovals: true, + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + settled: makeThread({ + id: ThreadId.make("tick-settled"), + title: "tick settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }), + snoozed: makeThread({ + id: ThreadId.make("tick-snoozed"), + title: "tick snoozed", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS), + }), + }; +} + +function buildTickList( + threads: ReadonlyArray, + clockMs: number, + pendingTasks: ReadonlyArray, + options?: { + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly queuedThreadKeys?: ReadonlySet; + readonly moveAvailability?: ReadonlyMap; + readonly shelfPreferencesLoading?: boolean; + }, +): ThreadListV2ListItem[] { + const now = isoAt(clockMs); + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now, + snoozedShelfExpanded: true, + }); + return buildThreadListV2ListItems({ + items: layout.items, + pendingTasks, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + snoozeLabelNow: now, + ...(options?.snoozeEnvironmentIds + ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds } + : {}), + ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}), + ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}), + ...(options?.shelfPreferencesLoading !== undefined + ? { shelfPreferencesLoading: options.shelfPreferencesLoading } + : {}), + }); +} + +function itemsByThreadKey(items: ReadonlyArray) { + const byKey = new Map(); + for (const item of items) byKey.set(item.key, item); + return byKey; +} + +describe("threadListV2ListItemsAreEqual", () => { + const thread = makeThread({ + id: ThreadId.make("eq"), + title: "eq", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }); + // One queued task object shared across builds: identity, not content, is + // what the row equality compares (mirrors the store's stable references). + const queued = makePendingTask("eq-queued"); + const build = () => + buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [queued], + snoozeLabelNow: NOW, + }); + + it("treats rebuilt wrappers over identical rows as equal", () => { + const first = build(); + const second = build(); + expect(first.length).toBe(second.length); + for (let index = 0; index < first.length; index += 1) { + expect(first[index]).not.toBe(second[index]); + expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true); + } + }); + + it("notices a replaced thread shell", () => { + const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" }); + const rebuilt = buildThreadListV2Items({ + threads: [replacement], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const next = buildThreadListV2ListItems({ + items: rebuilt.items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + const previousThread = build().find((item) => item.type === "v2-thread")!; + const nextThread = next.find((item) => item.type === "v2-thread")!; + expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false); + }); + + it("notices a changed wake countdown label", () => { + const snoozedLayout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("wake"), + title: "wake", + snoozedAt: NOW, + snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS), + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + snoozedShelfExpanded: true, + }); + const earlier = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: NOW, + }); + const later = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS), + }); + const rows = (items: ThreadListV2ListItem[]) => { + const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`); + expect(row?.type).toBe("v2-thread"); + if (row?.type !== "v2-thread") throw new Error("missing snoozed row"); + return row; + }; + expect(rows(earlier).snoozeWakeLabelText).toBe("2h"); + expect(rows(later).snoozeWakeLabelText).toBe("1h"); + expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); + }); + + it("notices shelf count, expansion, and loading-disabled changes", () => { + const shelf = { + type: "v2-settled-shelf", + key: "v2-settled-shelf", + count: 2, + expanded: true, + disabled: false, + } as const; + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false); + // A recycled cell ignores the render closure, so the shelf header's + // preference-loading disabled state has to ride on the item too. + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false); + }); + + it("treats different item kinds as unequal", () => { + const built = build(); + const threadItem = built.find((item) => item.type === "v2-thread")!; + const pendingItem = built.find((item) => item.type === "v2-pending")!; + expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false); + }); + + it("notices a trailing-divider flip caused by a neighbour change", () => { + const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" }); + const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" }); + const bare = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, threadB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + // The same shells with B settled: A now sits above the Settled section + // rule instead of another row, so A's hairline must flip through the + // recycled equality — its own shell reference never changed. + const settledB = makeThread({ + id: ThreadId.make("flip-b"), + title: "flip b", + settledOverride: "settled", + settledAt: NOW, + }); + const withSettled = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, settledB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + settledCount: 1, + settledShelfHeaderIndex: 1, + snoozeLabelNow: NOW, + }); + const firstA = bare[0]!; + const secondA = withSettled[0]!; + expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true); + expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false); + expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false); + }); +}); + +describe("isThreadListV2ListItem", () => { + it("narrows the v2 kinds and rejects the legacy discriminators", () => { + expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "thread" })).toBe(false); + expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false); + }); +}); + +describe("buildThreadListV2ListItems clock scoping", () => { + const allEnvironments = new Set([environmentId]); + const tickQueued = () => [makePendingTask("tick-queued")]; + + it("carries the snooze menu clock on every row whose swipe menu offers presets", () => { + const threads = buildTickThreads(); + const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), { + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = itemsByThreadKey(items); + const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!; + const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!; + const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!; + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW); + // The swipe-revealed snooze action exists on slim rows too (the variant + // only swaps the primary action), so settled rows need the fresh clock. + expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW); + // Approval rows are never snoozable; snoozed rows only offer Wake. + expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined(); + expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined(); + }); + + it("keeps the snooze menu clock off rows on servers without the capability", () => { + const threads = buildTickThreads(); + const items = buildTickList([threads.ready], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + const ready = items.find((item) => item.type === "v2-thread")!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined(); + }); + + it("blanks the precomputed time for rows that render a label instead", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const working = makeThread({ + id: ThreadId.make("tick-working"), + title: "tick working", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + session: runningSession("tick-working"), + }); + const threads = buildTickThreads(); + const items = buildTickList( + [threads.ready, working, threads.settled, threads.snoozed], + BASE_MS, + tickQueued(), + ); + const byKey = itemsByThreadKey(items); + const label = (key: string) => { + const item = byKey.get(key)!; + return item.type === "v2-thread" ? item.timeLabel : ""; + }; + // Ready cards and settled/snoozed slim rows draw a time; the wake + // countdown outranks the time on snoozed rows; cards with a status + // label never draw one. + expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m"); + expect(label(`v2-thread:${environmentId}:tick-working`)).toBe(""); + expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d"); + expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe(""); + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("thread list v2 minute tick invalidation", () => { + it("only invalidates rows whose clock-driven content moved", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const threads = buildTickThreads(); + const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed]; + // One queued-task reference shared by both builds: the store hands the + // list the same pending-task objects between rebuilds. + const pendingTasks = [makePendingTask("tick-queued")]; + const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks); + + expect(atStart.length).toBe(atNextMinute.length); + const invalidated: string[] = []; + for (let index = 0; index < atStart.length; index += 1) { + if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) { + invalidated.push(atStart[index]!.key); + } + } + // The ready row draws a minute-granular time and carries the snooze + // menu, and the settled slim row's swipe-revealed snooze menu shows + // preset times too, so both rows' menu content moved. Every other row + // — the approval card (status label, never snoozable), the snoozed + // shelf row ("2h" unchanged, Wake only), the shelf headers, and the + // queued row — survives the tick untouched. + expect(invalidated).toEqual([ + `v2-thread:${environmentId}:tick-ready`, + `v2-thread:${environmentId}:tick-settled`, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const staleReady = makeThread({ + id: ThreadId.make("tick-stale"), + title: "tick stale", + latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS), + }); + const atStart = buildTickList([staleReady], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("re-renders the snoozed countdown row when the wake label advances", () => { + vi.useFakeTimers(); + try { + const wakeAt = BASE_MS + 120 * MINUTE_MS; + const snoozed = makeThread({ + id: ThreadId.make("tick-wake"), + title: "tick wake", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(wakeAt), + }); + const wakeKey = `v2-thread:${environmentId}:tick-wake`; + const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!; + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([snoozed], BASE_MS, []); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []); + expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true); + // Minutes round up, so the countdown holds "2h" until the remaining + // time drops to the hour boundary — and only that row flips when it + // finally moves to minute granularity. + vi.setSystemTime(BASE_MS + 61 * MINUTE_MS); + const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []); + const oneHourRow = wakeRow(oneHour); + expect(oneHourRow.type).toBe("v2-thread"); + expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m"); + expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("buildThreadListV2ListItems trailing dividers", () => { + it("follows the final neighbour order, not the pre-splice blocks", () => { + const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" }); + const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" }); + const layout = buildThreadListV2Items({ + threads: [activeA, activeB], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")], + snoozeLabelNow: NOW, + }); + const dividers = items.map((item) => + item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a", + ); + // thread A | thread B | queued 1 | queued 2: consecutive threads keep + // their hairlines, the row before the Unsent section rule loses its own, + // queued rows divide each other, and the last row has nothing under it. + expect(dividers).toEqual([true, false, true, false]); + }); +}); + +describe("buildThreadListV2ListItems row-state stamps", () => { + const readyThread = makeThread({ + id: ThreadId.make("stamp-ready"), + title: "stamp ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const settledThread = makeThread({ + id: ThreadId.make("stamp-settled"), + title: "stamp settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }); + const allEnvironments = new Set([environmentId]); + + it("stamps queued outbox messages onto the matching row and notices removal", () => { + // An outbox write never touches the thread shell, so the queued icon has + // to ride on the item for the recycled cell to ever update it. + const queued = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]), + snoozeEnvironmentIds: allEnvironments, + }); + const plain = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set(), + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items); + const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!; + const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true); + expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false); + expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false); + // The neighbour row is untouched by the outbox change. + expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true); + }); + + it("notices move-availability changes on card rows without a shell update", () => { + const permissive = new Map([ + [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }], + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const blocked = new Map([ + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const open = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: permissive, + snoozeEnvironmentIds: allEnvironments, + }); + const closed = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: blocked, + snoozeEnvironmentIds: allEnvironments, + }); + const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!; + expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true); + expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false); + // Slim rows never carry the move actions, so availability is inert there. + const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true); + }); + + it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([settledThread], BASE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + const rowAtStart = atStart.find((item) => item.type === "v2-thread")!; + const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!; + // The swipe-revealed secondary action carries the snooze preset menu on + // slim rows; its minute clock must move, or the displayed wake times + // drift while the row is recycled-stable. + expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim"); + expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS)); + expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe( + isoAt(BASE_MS + MINUTE_MS), + ); + expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("stamps the shelf loading-disabled state so recycled headers refresh", () => { + const loading = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: true, + snoozeEnvironmentIds: allEnvironments, + }); + const loaded = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: false, + snoozeEnvironmentIds: allEnvironments, + }); + const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!; + const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!; + expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true); + expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false); + expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e1cb8e9ece7f..8c4dd0b0f097 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,6 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { + canSnooze, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, @@ -13,9 +14,13 @@ import { sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, + sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { ThreadMoveAvailability } from "./threadOrder"; + +import { relativeTime } from "../../lib/time"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -112,26 +117,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; -/** - * The flat Thread List v2 is the default on every app variant; the Settings → - * Legacy toggle opts a device back into the grouped legacy list. Preferences - * persist as sparse patches, so `undefined` genuinely means "never chosen". - * - * `preferencesLoaded` guards the startup window: preferences load - * asynchronously, and rendering one list before the stored choice arrives would - * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit legacy opt-in lands anyway. - */ -export function resolveThreadListV2Enabled(input: { - readonly legacyPreference: boolean | undefined; - readonly preferencesLoaded: boolean; -}): boolean { - if (!input.preferencesLoaded) { - return true; - } - return input.legacyPreference !== true; -} - export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { @@ -244,6 +229,28 @@ export interface ThreadListV2ThreadListItem { readonly item: ThreadListV2Item; /** Precomputed so recycled-list equality can see a minute-tick change. */ readonly snoozeWakeLabelText: string | undefined; + /** Row timestamp precomputed against the parent clock so the recycler's + equality only sees a change on rows that actually draw a time. Blank + while the row renders a status label or the wake countdown instead. */ + readonly timeLabel: string; + /** Minute clock feeding the row's native snooze menu, carried only on rows + whose menu holds snooze presets (capability-gated cards the user may + snooze). Keeping it out of the list's `extraData` means the minute tick + re-renders just these rows instead of every visible one. */ + readonly snoozePresetMinute: string | undefined; + /** Inset hairline drawn under the row. Precomputed from the final order so + a neighbour change (e.g. the queued block appearing) updates the row + through recycled-list equality instead of leaving a stale divider. */ + readonly showTrailingDivider: boolean; + /** A message for this thread is waiting in the outbox. Carried on the item + so an outbox write (which never touches the thread shell) reaches the + row through recycled-list equality instead of leaving a stale icon. */ + readonly hasQueuedMessages: boolean; + /** Move up/down availability in the row's card section. Carried on the item + for the same reason: a reorder in flight (or its commit) changes menu + availability without changing any shell. */ + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; } export interface ThreadListV2PendingListItem { @@ -252,6 +259,9 @@ export interface ThreadListV2PendingListItem { readonly pendingTask: PendingNewTask; /** First queued row after the active block draws the PENDING divider. */ readonly showPendingDivider: boolean; + /** Same rule as the thread rows: a hairline unless the next row carries its + own section rule or none follows. */ + readonly showTrailingDivider: boolean; } export interface ThreadListV2SnoozedShelfListItem { @@ -259,6 +269,11 @@ export interface ThreadListV2SnoozedShelfListItem { readonly key: "v2-snoozed-shelf"; readonly count: number; readonly expanded: boolean; + /** Shelf preferences still loading: the toggle is disabled until they + arrive. Carried on the item because a recycled cell ignores the render + closure — without it the header would stay visibly disabled (or enabled + too early) after the preference load lands. */ + readonly disabled: boolean; } export interface ThreadListV2SettledShelfListItem { @@ -266,6 +281,8 @@ export interface ThreadListV2SettledShelfListItem { readonly key: "v2-settled-shelf"; readonly count: number; readonly expanded: boolean; + /** See the snoozed shelf header's field. */ + readonly disabled: boolean; } export type ThreadListV2ListItem = @@ -274,6 +291,90 @@ export type ThreadListV2ListItem = | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; +/** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to + the v2 item kinds the shared equality understands. */ +export function isThreadListV2ListItem(value: { + readonly type: string; +}): value is ThreadListV2ListItem { + return ( + value.type === "v2-thread" || + value.type === "v2-pending" || + value.type === "v2-snoozed-shelf" || + value.type === "v2-settled-shelf" + ); +} + +/** Recycled-list equality for the flat v2 list (Home + iPad sidebar). + Item objects are rebuilt on every minute tick and every partition run; + without this the lists would consider every mounted row changed and + re-render all of them (each carrying a swipeable + a PR subscription). + The clock-derived fields are per-row precomputed text, so a minute tick + only flips equality on rows whose visible text (or snooze menu) actually + moved. Thread references are stable across rebuilds. */ +export function threadListV2ListItemsAreEqual( + previous: ThreadListV2ListItem, + item: ThreadListV2ListItem, +): boolean { + switch (item.type) { + case "v2-thread": + return ( + previous.type === "v2-thread" && + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.snoozed === item.item.snoozed && + previous.item.pinned === item.item.pinned && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText && + previous.timeLabel === item.timeLabel && + previous.snoozePresetMinute === item.snoozePresetMinute && + previous.showTrailingDivider === item.showTrailingDivider && + previous.hasQueuedMessages === item.hasQueuedMessages && + previous.canMoveUp === item.canMoveUp && + previous.canMoveDown === item.canMoveDown + ); + case "v2-pending": + return ( + previous.type === "v2-pending" && + previous.key === item.key && + previous.pendingTask === item.pendingTask && + previous.showPendingDivider === item.showPendingDivider && + previous.showTrailingDivider === item.showTrailingDivider + ); + case "v2-snoozed-shelf": + return ( + previous.type === "v2-snoozed-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + case "v2-settled-shelf": + return ( + previous.type === "v2-settled-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + } +} + +/** The timestamp a row renders when it shows no status label: the settle + stamp on settled slim rows, otherwise the latest activity. Blank for + status-labelled cards and snoozed rows with a wake countdown — those + never draw a time, so their minute tick must not invalidate the cell. */ +function resolveThreadListV2ItemTimeLabel( + item: ThreadListV2Item, + showSnoozeWakeLabel: boolean, +): string { + const { thread, variant, snoozed } = item; + if (showSnoozeWakeLabel) return ""; + if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return ""; + const settledTimestamp = + variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; + return relativeTime( + settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ); +} + /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains @@ -289,21 +390,64 @@ export function buildThreadListV2ListItems(input: { readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; + /** Environments whose server supports thread.snooze. Rows on other + environments never carry the minute clock that feeds the snooze menu. + Absent = no gating (tests). */ + readonly snoozeEnvironmentIds?: ReadonlySet; + /** Thread keys (`environmentId:threadId`) with a message waiting in the + outbox; stamped onto the matching rows as `hasQueuedMessages`. */ + readonly queuedThreadKeys?: ReadonlySet; + /** Menu availability for Move up/down, keyed by `environmentId:threadId` + (only consulted for card rows — slim menus omit the moves). Produced by + `computeThreadMoveAvailability` in one pass per section; a recycled cell + ignores the render closure, so the stamps ride on the item. Absent = + never available (tests). */ + readonly moveAvailability?: ReadonlyMap; + /** True while the shelf expansion preferences are still loading; stamped + onto both shelf headers so the disabled state reaches recycled cells. */ + readonly shelfPreferencesLoading?: boolean; }): ThreadListV2ListItem[] { - const threadItems = input.items.map((item): ThreadListV2ListItem => ({ - type: "v2-thread", - key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, - item, - snoozeWakeLabelText: + const threadItems = input.items.map((item): ThreadListV2ListItem => { + const snoozeWakeLabelText = item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) - : undefined, - })); + : undefined; + // The minute clock belongs on the item, not the list's extraData, so the + // recycler's equality can confine the per-minute re-render to rows whose + // snooze menu actually shows preset times. The swipe-revealed snooze menu + // exists on slim rows too (the variant only swaps the primary action), + // so the gate follows actual snooze availability, not the variant. + const snoozePresetMinute = + !item.snoozed && + input.snoozeLabelNow !== undefined && + (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) && + canSnooze(item.thread, { now: input.snoozeLabelNow }) + ? input.snoozeLabelNow + : undefined; + const move = + item.variant === "card" + ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) + : undefined; + return { + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + snoozeWakeLabelText, + timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined), + snoozePresetMinute, + showTrailingDivider: false, + hasQueuedMessages: + input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true, + canMoveUp: move?.canMoveUp === true, + canMoveDown: move?.canMoveDown === true, + }; + }); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, + showTrailingDivider: false, })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; @@ -312,12 +456,14 @@ export function buildThreadListV2ListItems(input: { const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; + const shelfDisabled = input.shelfPreferencesLoading === true; if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", key: "v2-snoozed-shelf", count: snoozedCount, expanded: input.snoozedShelfExpanded === true, + disabled: shelfDisabled, }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } @@ -327,10 +473,21 @@ export function buildThreadListV2ListItems(input: { key: "v2-settled-shelf", count: settledCount, expanded: input.settledShelfExpanded !== false, + disabled: shelfDisabled, }); result.push(...threadItems.slice(settledShelfHeaderIndex)); } - return result; + // Hairlines depend on the final neighbour, so they are stamped after the + // splice: a recycled cell only re-renders when its divider actually flips. + return result.map((entry, index) => { + if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry; + const next = result[index + 1]; + const showTrailingDivider = + next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider); + return showTrailingDivider === entry.showTrailingDivider + ? entry + : { ...entry, showTrailingDivider }; + }); } /** @@ -450,11 +607,7 @@ export function buildThreadListV2Items(input: { : orderedSnoozed.filter( (thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey, ); - const orderedSettled = [...settled].sort( - (left, right) => - parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") - - parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""), - ); + const orderedSettled = sortSettledThreads(settled); const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; const pagedSettled = orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts index c71c1040bf40..570251f4a985 100644 --- a/apps/mobile/src/features/threads/threadOrder.ts +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -1,5 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -42,7 +46,7 @@ export function threadOrderAfterMove( return result; } -type OrderRow = Pick< +export type OrderRow = Pick< EnvironmentThreadShell, | "id" | "environmentId" @@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: { }; } +export interface ThreadMoveAvailability { + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; +} + +/** + * Batch form of "call `createThreadMovePlanner` once per card": one pass over + * the section answers up/down availability for every ordered row, so list + * construction stays linear instead of one full planner probe (array copies, + * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of + * the planner's plan rules lives in the body below; the reference-parity test + * pins them row-by-row, including adversarial keys and hidden reservations. + */ +export function computeThreadMoveAvailability(input: { + readonly ordered: readonly OrderRow[]; + readonly allThreads?: readonly OrderRow[]; + readonly section: PendingThreadOrder["section"]; + readonly reorderableEnvironmentIds: ReadonlySet; + readonly pendingOrder?: PendingThreadOrder | null; +}): Map { + const result = new Map(); + // A reorder in flight locks the whole list until its receipt lands. + if (input.pendingOrder != null) return result; + const rows = input.ordered; + const orderedIds = rows.map(rowId); + const indexById = new Map(orderedIds.map((id, index) => [id, index] as const)); + const keysById = new Map( + (input.allThreads ?? input.ordered).map( + (row) => [rowId(row), rowOrder(row, input.section).key] as const, + ), + ); + const writableIds = new Set( + (input.allThreads ?? input.ordered) + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); + // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is + // answered in O(1) amortized instead of one planner probe per row: + // + // Fast path (both neighbors keyed): the fresh key between the landing + // neighbors, walking forward while hidden reserved keys block it. The walk + // depends only on the neighbor key pair - each adjacency is probed by at + // most two rows (down of the left member, up of the right member) - so the + // memo keeps even a fully adversarial reserved-key layout linear per build. + // + // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh + // spread keys for every row; only positions whose current key differs get + // written. A swap permutes two rows without changing the multiset, so the + // assignment set differs from the unswapped baseline at at most those two + // positions, and mismatch/writability tallies computed once per section + // answer each row with a constant-size delta. + const midpoints = new Map(); + const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => { + const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`; + const cached = midpoints.get(memoKey); + if (cached !== undefined) return cached; + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); + midpoints.set(memoKey, key); + return key; + }; + const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); + const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null); + const writableRow = orderedIds.map((id) => writableIds.has(id)); + let baselineWrites = 0; + let baselineUnwritableWrites = 0; + for (let position = 0; position < orderedIds.length; position += 1) { + if (currentKeys[position] === spreadKeys[position]) continue; + baselineWrites += 1; + if (!writableRow[position]) baselineUnwritableWrites += 1; + } + // `movedId` swaps with its neighbor; ids at the two swapped positions change, + // so only their tally contributions are recomputed. + const rewriteViable = (index: number): boolean => { + let writes = baselineWrites; + let unwritableWrites = baselineUnwritableWrites; + for (const position of [index, index + 1]) { + const other = position === index ? index + 1 : index; + if (currentKeys[position] !== spreadKeys[position]) { + writes -= 1; + if (!writableRow[position]) unwritableWrites -= 1; + } + // After the swap this position holds the row that was at `other`. + if (currentKeys[other] !== spreadKeys[position]) { + writes += 1; + if (!writableRow[other]) unwritableWrites += 1; + } + } + return writes > 0 && unwritableWrites === 0; + }; + for (const row of rows) { + const movedId = rowId(row); + const denied = { canMoveUp: false, canMoveDown: false }; + if (!writableIds.has(movedId)) { + result.set(movedId, denied); + continue; + } + const index = indexById.get(movedId); + if (index === undefined) { + result.set(movedId, denied); + continue; + } + const adjacentAvailable = (towardUp: boolean): boolean => { + const shifted = index + (towardUp ? -1 : 1); + if (shifted < 0 || shifted >= orderedIds.length) return false; + // The swap exchanges the row with its neighbor; afterwards the moved row + // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD + // order: moving up it lands between old(index-2) and old(index-1), + // moving down between old(index+1) and old(index+2). + const beforeIndex = towardUp ? index - 2 : index + 1; + const afterIndex = towardUp ? index - 1 : index + 2; + const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null); + const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null); + const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null); + const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null); + if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) { + const key = fastPathKey(beforeKey, afterKey); + // A fresh key is a single-write plan for the (writable) moved row. + if (key !== null) return true; + } + // Keyless neighbor or exhausted key space: the section rewrite runs. + return rewriteViable(Math.min(index, shifted)); + }; + result.set(movedId, { + canMoveUp: adjacentAvailable(true), + canMoveDown: adjacentAvailable(false), + }); + } + return result; +} + export function createPendingThreadOrder(input: { readonly section: PendingThreadOrder["section"]; readonly ordered: readonly OrderRow[]; diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts new file mode 100644 index 000000000000..765eff4cf61e --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + computeThreadMoveAvailability, + createThreadMovePlanner, + type OrderRow, +} from "./threadOrder"; + +// The batch availability computation must answer exactly what the reference +// per-move planner answers; these tests randomize sections that stress every +// branch (keyless rows, hidden keys, non-writable rows, adversarial keys). + +function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow { + return { + id: id as OrderRow["id"], + environmentId: environmentId as OrderRow["environmentId"], + pinOrderKey: pinned ? key : null, + activeOrderKey: pinned ? null : key, + createdAt: "2026-01-01T00:00:00.000Z", + unsettledAt: "2026-01-01T00:00:00.000Z", + pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null, + }; +} + +function makeRng(seed: number) { + let state = seed >>> 0; + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +const KEY_POOL = [ + "a", + "b", + "ba", + "bb", + "c", + "d", + "ca", + "cb", + "da", + "db", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + null, +]; + +function referenceAvailability( + ordered: readonly OrderRow[], + allThreads: readonly OrderRow[], + writable: ReadonlySet, +) { + const planner = createThreadMovePlanner({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: writable, + }); + const answers = new Map(); + for (const row of ordered) { + const movedId = `${row.environmentId}:${row.id}`; + answers.set(movedId, { + canMoveUp: planner(movedId, "up") !== null, + canMoveDown: planner(movedId, "down") !== null, + }); + } + return answers; +} + +function randomCase(rng: () => number) { + const rowCount = 1 + Math.floor(rng() * 9); + // Two environments; "writable" env vs. one lacking the reorder capability. + const rows: OrderRow[] = []; + for (let index = 0; index < rowCount; index += 1) { + const environment = rng() < 0.75 ? "env-w" : "env-x"; + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + // Ids with colons: a `${environmentId}:${id}` string is not splittable + // back into its parts, so batch and planner must agree even here. + const id = rng() < 0.4 ? `t:${index}` : `t${index}`; + rows.push(makeRow(id, environment, key, true)); + } + // Hidden rows (in allThreads, not in the visible ordered section) may hold + // keys that collide with fast-path midpoints. + const hidden: OrderRow[] = []; + const hiddenCount = Math.floor(rng() * 4); + for (let index = 0; index < hiddenCount; index += 1) { + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + const id = rng() < 0.4 ? `h:${index}` : `h${index}`; + hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true)); + } + return { ordered: rows, allThreads: [...rows, ...hidden] }; +} + +const WRITABLE = new Set(["env-w" as EnvironmentId]); + +describe("computeThreadMoveAvailability matches the reference planner", () => { + it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => { + for (let seed = 1; seed <= 4_000; seed += 1) { + const rng = makeRng(seed); + const { ordered, allThreads } = randomCase(rng); + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + for (const [id, answer] of reference) { + expect( + batch.get(id) ?? { canMoveUp: false, canMoveDown: false }, + `seed ${seed} row ${id} ordered=${ordered + .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`) + .join(",")}`, + ).toEqual(answer); + } + } + }); + + it("locks every row while a pending reorder is in flight", () => { + const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)]; + const pending = { + section: "pinned" as const, + orderedIds: ["env-w:t1", "env-w:t0"], + before: new Map(), + assignments: new Map(), + confirmed: new Set(), + commandsComplete: false, + }; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + pendingOrder: pending, + }); + expect(batch.size).toBe(0); + }); + + it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => { + // Reported parity case: keys ["f","gn",null], middle row NOT writable. + // The rewrite assigns the middle row the key it already holds, so the + // diff never writes it and the first row's down-move stays available. + const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)]; + const withThird = [...rows, makeRow("t2", "env-w", null, true)]; + const batch = computeThreadMoveAvailability({ + ordered: withThird, + allThreads: withThird, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(withThird, withThird, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true }); + }); + + it("keeps moves available for ids containing colons (composite-id parsing)", () => { + // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting + // the composite id at the last colon yields `env:thread` and falsely locks + // both rows; writability must come from the row's own environmentId. + const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: new Set(["env" as EnvironmentId]), + }); + const reference = referenceAvailability( + rows, + rows, + new Set(["env" as EnvironmentId]), + ); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true }); + expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false }); + }); + + it("denies single-row sections on both directions", () => { + const rows = [makeRow("t0", "env-w", "a", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("denies rows whose section has non-writable neighbours when the fast path fails", () => { + // Keyless neighbors force the section-rewrite fallback; a non-writable + // neighbor makes the rewrite illegal for every row in the section. + const rows = [ + makeRow("t0", "env-w", null, true), + makeRow("t1", "env-x", "c", true), + makeRow("t2", "env-w", "d", true), + ]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + // Sanity: the middle (non-writable) row is denied on both sides. + expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => { + // The worst case for reserved-key collisions: one hidden row holds the + // exact midpoint key of every adjacent visible pair, so every probe walks. + const visibleKeys = generateSpreadPinOrderKeys(24); + const hiddenKeys = visibleKeys + .slice(0, -1) + .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!)) + .filter((key): key is string => key != null); + const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true)); + const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true)); + const allThreads = [...ordered, ...hidden]; + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); + + it("agrees on an adversarial section of consecutive single-char keys", () => { + // Every midpoint between consecutive one-char keys is unrepresentable, so + // no row may claim a fast-path plan. + const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true)); + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts deleted file mode 100644 index b5f286937e0c..000000000000 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { StatusTone } from "../../components/StatusPill"; -import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -export type ThreadStatusKind = - | "pending-approval" - | "awaiting-input" - | "working" - | "connecting" - | "error" - | "plan-ready"; - -export interface ThreadStatusPresentation extends StatusTone { - readonly kind: ThreadStatusKind; - /** Whether the indicator represents in-flight activity. */ - readonly pulse: boolean; -} - -function isLatestTurnSettled( - latestTurn: OrchestrationLatestTurn | null, - session: OrchestrationSession | null, -): boolean { - if (!latestTurn?.startedAt) return false; - if (!latestTurn.completedAt) return false; - if (!session) return true; - return session.status !== "running"; -} - -/** - * Resolves the user-facing status of a thread, in priority order. Returns - * `null` for quiescent threads so rows stay free of "Idle"-style noise. - * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. - */ -export function resolveThreadStatus( - thread: EnvironmentThreadShell, -): ThreadStatusPresentation | null { - if (thread.hasPendingApprovals) { - return { - kind: "pending-approval", - label: "Needs Approval", - pillClassName: "bg-warning", - textClassName: "text-warning-foreground", - pulse: false, - }; - } - - if (thread.hasPendingUserInput) { - return { - kind: "awaiting-input", - label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-600-300", - pulse: false, - }; - } - - if (thread.session?.status === "running") { - return { - kind: "working", - label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "starting") { - return { - kind: "connecting", - label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { - return { - kind: "error", - label: "Error", - pillClassName: "bg-danger", - textClassName: "text-danger-foreground", - pulse: false, - }; - } - - const hasPlanReadyPrompt = - thread.interactionMode === "plan" && - isLatestTurnSettled(thread.latestTurn, thread.session) && - thread.hasActionableProposedPlan; - if (hasPlanReadyPrompt) { - return { - kind: "plan-ready", - label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-600-400", - pulse: false, - }; - } - - return null; -} diff --git a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts index 61c4fb65cdc9..684049155a9e 100644 --- a/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts +++ b/apps/mobile/src/features/threads/use-legacy-plan-mode-enabled.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveLegacyPlanModeEnabled } from "./legacy-plan-mode"; +import { resolveLegacyPlanModeEnabled } from "../../state/legacy-plan-mode"; /** * Mobile preferences are device-local, matching the desktop client setting. diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts deleted file mode 100644 index 2672942c2d36..000000000000 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveThreadListV2Enabled } from "./threadListV2"; - -/** - * Resolved Thread List v2 state: on unless the device opted into the legacy - * grouped list (Settings → Legacy). Every consumer must read through this - * rather than the raw preference, which is undefined until explicitly chosen. - */ -export function useThreadListV2Enabled(): boolean { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferencesResult); - return resolveThreadListV2Enabled({ - legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, - preferencesLoaded: loaded, - }); -} diff --git a/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx b/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx new file mode 100644 index 000000000000..10f18a51acd1 --- /dev/null +++ b/apps/mobile/src/features/usage/ChatGptUsageSummary.tsx @@ -0,0 +1,41 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { CHATGPT_USAGE_URL, collectExternalUsageLinks } from "@t3tools/shared/usageLimits"; +import { Linking, Pressable, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { environmentPresentations } from "../../state/presentation"; + +export function ChatGptUsageSummary({ + selectedEnvironmentIds, +}: { + selectedEnvironmentIds: ReadonlySet | null; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const selected = + selectedEnvironmentIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); + const usage = collectExternalUsageLinks(selected).find((link) => link.url === CHATGPT_USAGE_URL); + if (!usage) return null; + return ( + + + + + ChatGPT shared usage + + void Linking.openURL(usage.url).catch(() => undefined)} + > + Manage usage + + + + {usage.accounts.join(", ")}. Open ChatGPT with the account you connected. + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 4e3e7339765c..36d01838b0e0 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -3,16 +3,19 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" import { EnvironmentId } from "@t3tools/contracts"; import { collectLimitAccounts, + collectExternalUsageLinks, collectLimitNotices, collectLimitPools, + cursorUsageWindowDetails, + displayLimitWindows, formatDuration, formatResetsIn, remainingPercent, type LimitAccount, type LimitPoolWindow, } from "@t3tools/shared/usageLimits"; -import { useId, useState } from "react"; -import { Pressable, ScrollView, View } from "react-native"; +import { Fragment, type ReactNode, useId, useState } from "react"; +import { Linking, Pressable, ScrollView, View } from "react-native"; import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -70,11 +73,15 @@ function PoolWindowCard({ color, now, environmentIds, + label, + description, }: { readonly pool: LimitPoolWindow; readonly color: string; readonly now: number; readonly environmentIds: readonly string[] | null; + readonly label?: string; + readonly description?: string; }) { const navigation = useNavigation(); const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); @@ -96,7 +103,7 @@ function PoolWindowCard({ - {pool.label} + {label ?? pool.label} {pool.remainingPercent}% @@ -108,6 +115,7 @@ function PoolWindowCard({ {PACE_LABEL[pool.pace]} ) : null} + {description ? {description} : null} {nextRefill ? ( ↻ +{nextRefill.restoresPercent}%{" "} @@ -196,10 +204,12 @@ export function UsageLimitsSection({ now, failedLabels, selectedEnvironmentIds, + cursorPrompt, }: { readonly now: number; readonly failedLabels: readonly string[]; readonly selectedEnvironmentIds: ReadonlySet | null; + readonly cursorPrompt?: ReactNode; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const selected = @@ -208,33 +218,74 @@ export function UsageLimitsSection({ : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); const pools = collectLimitPools(collectLimitAccounts(selected), now); const notices = collectLimitNotices(selected); + const externalLinks = collectExternalUsageLinks(selected); const colors = useProviderColors(); + const cursorPromptAt = + Math.max( + pools.findIndex((pool) => pool.driver === "codex"), + pools.findIndex((pool) => pool.driver === "claudeAgent"), + ) + 1; return ( - {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( + {pools.length === 0 && + notices.length === 0 && + failedLabels.length === 0 && + !cursorPrompt && + externalLinks.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." : "No provider on the selected environments reports subscription limits."} ) : null} - {pools.map((pool) => ( - - - - - {DRIVER_LABEL[pool.driver] ?? pool.driver} - - - {pool.windows.map((window) => ( - - ))} + {pools.map((pool, index) => { + const windows = displayLimitWindows(pool); + return ( + + {index === cursorPromptAt ? cursorPrompt : null} + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {windows.map((window) => { + const details = + pool.driver === "cursor" ? cursorUsageWindowDetails(window.id) : undefined; + return ( + + ); + })} + + + ); + })} + {cursorPromptAt === pools.length ? cursorPrompt : null} + {externalLinks.map((link) => ( + + {link.label} + {link.accounts.join(", ")} + {link.message ? ( + {link.message} + ) : null} + void Linking.openURL(link.url).catch(() => undefined)} + > + Manage usage + ))} {notices.length > 0 || failedLabels.length > 0 ? ( diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 5383602869bb..144c9d18e8e4 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,7 +18,7 @@ import { } from "@t3tools/shared/usageLimits"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; -import { Alert, Pressable, View } from "react-native"; +import { Alert, Linking, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { ProviderIcon } from "../../components/ProviderIcon"; @@ -140,6 +140,7 @@ export function AccountLimits(props: { const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const externalUsage = limits.externalUsage; const padding = dense ? "px-4 py-3" : "p-4"; return ( )} + {externalUsage ? ( + void Linking.openURL(externalUsage.url).catch(() => undefined)} + > + Manage usage + + ) : null} {props.footer} ); @@ -294,7 +304,7 @@ export function useRefreshLimits( const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); - const refresh = async (automatic = false) => { + const refresh = async (automatic = false, afterPending = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && @@ -307,6 +317,7 @@ export function useRefreshLimits( environmentId, () => refreshProviders({ environmentId, input: {} }), automatic, + afterPending, ); if (result === undefined) return; setFailedEnvironments((previous) => [ @@ -354,5 +365,11 @@ export function useRefreshLimits( selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh: refreshManually }; + return { + now, + refreshing, + failedLabels, + refresh: refreshManually, + refreshAfterEnable: () => refresh(false, true), + }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index d1bec8cbc140..e846425af6f6 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,6 +1,8 @@ +import { ChatGptUsageSummary } from "./ChatGptUsageSummary"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { type RouteProp, useIsFocused, useNavigation, useRoute } from "@react-navigation/native"; +import { cursorKeychainAccessEnvironments } from "@t3tools/client-runtime/state/usage"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -15,6 +17,7 @@ import { formatHourShort, formatPercent, formatTokens, + formatUsageContractMismatch, formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; @@ -25,9 +28,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SegmentedControl } from "../../components/SegmentedControl"; import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { SettingsScreen } from "../settings/components/SettingsScreen"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; @@ -59,6 +65,7 @@ const METRIC_OPTIONS = [ ] as const satisfies readonly { value: UsageChartMetric; label: string }[]; const CHART_HEIGHT = 180; +const CURSOR_KEYCHAIN_COPY = "Requires access to your Cursor login in macOS Keychain."; /** * Two tabs over one screen. Usage is the transcript-derived spend for a @@ -97,6 +104,27 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); + const cursorAccessEnvironments = cursorKeychainAccessEnvironments(selectedEnvironments); + const refreshAfterCursorEnable = () => { + void refresh(); + void limits.refreshAfterEnable(); + }; + const sourceMessages = [ + ...new Set( + selectedEnvironments.flatMap( + (environment) => + environment.summary?.sources.flatMap((source) => + source.message && + !source.action && + (source.status === "partial" || + source.status === "failed" || + source.fingerprint.provider === "cursor") + ? [source.message] + : [], + ) ?? [], + ), + ), + ]; const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -251,7 +279,6 @@ export function UsageRouteScreen() { } > - 0 ? ( + + ) : null + } /> ) : ( <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} - + + {merged.duplicateSources.length > 0 ? ( Counted once across environments sharing a transcript directory:{" "} @@ -301,6 +337,11 @@ export function UsageRouteScreen() { ) : ( <> + {sourceMessages.map((message) => ( + + {message} + + ))} - + 1} + onCursorEnabled={refreshAfterCursorEnable} + /> @@ -324,6 +371,110 @@ export function UsageRouteScreen() { ); } +function CursorEnableAction({ + environmentId, + label, + onEnabled, + buttonText = "Enable", +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onEnabled: () => void; + readonly buttonText?: string; +}) { + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + label: "enable Cursor account usage", + }); + const [pending, setPending] = useState(false); + const enable = async () => { + setPending(true); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { cursorKeychainUsageEnabled: true } }, + }); + if (result._tag === "Success") onEnabled(); + } finally { + setPending(false); + } + }; + return ( + void enable()} + className="rounded-full bg-primary px-4 py-2" + > + {buttonText} + + ); +} + +function CursorEnableRow({ + environmentId, + label, + showEnvironment, + bordered, + onEnabled, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly showEnvironment: boolean; + readonly bordered: boolean; + readonly onEnabled: () => void; +}) { + const colors = useProviderColors(); + return ( + + + + + Cursor{showEnvironment ? ` · ${label}` : ""} + + + + + ); +} + +function CursorEnableLimits({ + environments, + onEnabled, +}: { + readonly environments: readonly EnvironmentUsageStatus[]; + readonly onEnabled: () => void; +}) { + return ( + + + + Cursor + + + {CURSOR_KEYCHAIN_COPY} + + {environments.map((environment) => ( + 1 ? `Enable on ${environment.label}` : "Enable"} + onEnabled={onEnabled} + /> + ))} + + + + ); +} + /** Headline figure, the animated daily chart, and its legend, in one card. */ function ChartCard(props: { readonly merged: MergedUsage; @@ -400,20 +551,53 @@ function ChartCard(props: { function ProviderSection(props: { readonly merged: MergedUsage; readonly metric: UsageChartMetric; + readonly cursorAccessEnvironments: readonly EnvironmentUsageStatus[]; + readonly showCursorEnvironment: boolean; + readonly onCursorEnabled: () => void; }) { const { merged, metric } = props; const colors = useProviderColors(); - if (merged.providers.length === 0) return null; + if (merged.providers.length === 0 && props.cursorAccessEnvironments.length === 0) return null; // Ranked by whatever the toggle is showing, so the rows always descend. // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 method. const ordered = [...merged.providers].sort((a, b) => metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, ); + const rows: Array< + | { readonly kind: "usage"; readonly provider: (typeof ordered)[number] } + | { readonly kind: "enable"; readonly environment: EnvironmentUsageStatus } + > = ordered.map((provider) => ({ kind: "usage", provider })); + const cursorInsertAt = + Math.max( + ordered.findIndex((provider) => provider.provider === "codex"), + ordered.findIndex((provider) => provider.provider === "claude"), + ) + 1; + rows.splice( + cursorInsertAt, + 0, + ...props.cursorAccessEnvironments.map((environment) => ({ + kind: "enable" as const, + environment, + })), + ); return ( - {ordered.map((provider, index) => { + {rows.map((row, index) => { + if (row.kind === "enable") { + return ( + 0} + onEnabled={props.onCursorEnabled} + /> + ); + } + const provider = row.provider; const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( = { claude: "Claude Code", codex: "Codex", grok: "Grok Build", + cursor: "Cursor", + opencode: "OpenCode", + antigravity: "Antigravity", }; /** @@ -23,5 +33,8 @@ export function useProviderColors(): Record { claude: "#d97757", codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43", grok: scheme === "dark" ? "#a1a1aa" : "#52525b", + cursor: "#8b8b8b", + opencode: "#5b9bbd", + antigravity: "#8c7bd1", }; } diff --git a/apps/mobile/src/lib/androidControlSizing.test.ts b/apps/mobile/src/lib/androidControlSizing.test.ts new file mode 100644 index 000000000000..8b9fd9751cbb --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAndroidControlSizing } from "./androidControlSizing"; + +describe("Android control sizing", () => { + it.each([ + [11, 17, 48, 48, 172, 48], + [16, 24, 48, 56, 250, 48], + [22, 33, 66, 77, 344, 66], + ])( + "scales controls at %ipt", + (fontSize, iconSize, buttonSize, fabSize, menuWidth, menuItemHeight) => { + expect(resolveAndroidControlSizing(fontSize)).toMatchObject({ + iconSize, + buttonSize, + fabSize, + menuWidth, + menuItemHeight, + }); + }, + ); +}); diff --git a/apps/mobile/src/lib/androidControlSizing.ts b/apps/mobile/src/lib/androidControlSizing.ts new file mode 100644 index 000000000000..f6868ad07bdd --- /dev/null +++ b/apps/mobile/src/lib/androidControlSizing.ts @@ -0,0 +1,23 @@ +import { DEFAULT_BASE_FONT_SIZE, normalizeBaseFontSize } from "./appearancePreferences"; + +/** Android controls follow the app's text size; buttons and menu rows retain a 48dp touch target. */ +export function resolveAndroidControlSizing(baseFontSize: number) { + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + const iconSize = Math.round(24 * scale); + const buttonSize = Math.max(48, Math.round(48 * scale)); + const fabSize = Math.max(48, Math.round(56 * scale)); + + return { + scale, + iconSize, + smallIconSize: Math.round(16 * scale), + mediumIconSize: Math.round(18 * scale), + buttonSize, + fabSize, + largeFabSize: Math.round(96 * scale), + menuWidth: Math.round(250 * scale), + menuItemHeight: Math.max(48, Math.round(48 * scale)), + // Two floating actions, their gap, and the space below the lower action. + fabClearance: fabSize * 2 + 36, + }; +} diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index 2ce6a8b5a367..981b35808a54 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -219,6 +219,16 @@ export function scaledTypographyLineHeight( return Math.max(10, Math.round(role.lineHeight * scale)); } +/** Text dimensions shared by React Native and Compose consumers of an appearance role. */ +export function resolveScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY, baseFontSize: number) { + const typography = MOBILE_TYPOGRAPHY[role]; + const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; + return { + fontSize: Math.max(8, Math.round(typography.fontSize * scale)), + lineHeight: scaledTypographyLineHeight(typography, baseFontSize), + }; +} + export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography { const fontSizes = resolveMarkdownFontSizes(baseFontSize); return { diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 2c6b27864432..df68a2de3019 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -32,7 +32,7 @@ vi.mock("../state/atom-registry", () => ({ })); // The real read lease and cleanup are covered by the composer ownership suite. -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: () => () => {}, })); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index 10f4fdb7c6c7..1bce3e26774f 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,8 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; import { isComposerImageAttachment, isFileBackedComposerAttachment, diff --git a/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts new file mode 100644 index 000000000000..ca9044f32dc6 --- /dev/null +++ b/apps/mobile/src/lib/composerAttachmentPreviewRetention.ts @@ -0,0 +1,28 @@ +import type { FileBackedComposerAttachment } from "./composerImages"; +import { retainComposerAttachmentFile } from "./composerAttachmentFiles"; + +/** + * Preview retention for saved composer attachment copies. + * + * The durable owners of an attachment file (composer drafts, queued outbox + * messages) live in state, so this module cannot reach the ownership-cleanup + * sweep directly. Composer draft state registers the owner-side cleanup hook + * at module load; until then a release has nothing to retry, because no draft + * store has loaded yet and there is nothing to clean. + */ +type UnusedAttachmentHandler = (attachment: FileBackedComposerAttachment) => void; + +let onAttachmentUnused: UnusedAttachmentHandler | null = null; + +export function registerComposerAttachmentUnusedHandler(handler: UnusedAttachmentHandler): void { + onAttachmentUnused = handler; +} + +/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ +export function retainComposerAttachmentFileForPreview( + attachment: FileBackedComposerAttachment, +): () => void { + return retainComposerAttachmentFile(attachment.fileUri, () => { + onAttachmentUnused?.(attachment); + }); +} diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 000000000000..447973503333 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,64 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { isEnabled, log }; +} diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 3693caa41185..602d5aada56b 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ exists: vi.fn(), })); -vi.mock("../state/use-composer-drafts", () => ({ +vi.mock("./composerAttachmentPreviewRetention", () => ({ retainComposerAttachmentFileForPreview: mocks.retain, })); vi.mock("./attachmentDownload", () => ({ shareLocalAttachment: mocks.share })); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 0d90f7210bab..171fc26c0566 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -3,7 +3,7 @@ import { videoMimeType } from "@t3tools/shared/video"; import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; -import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "./composerAttachmentPreviewRetention"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts similarity index 97% rename from apps/mobile/src/lib/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts index adde56fbf0b1..1e7344a684c3 100644 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.test.ts @@ -27,7 +27,7 @@ vi.mock("expo-file-system", () => ({ }, })); -import { downscaleProjectFavicon } from "./projectFaviconCache"; +import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache"; const png = "iVBORw0KGgoAAAAA"; const image = { url: "https://remote/icon.png" }; diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts similarity index 90% rename from apps/mobile/src/lib/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconDatabaseCache.ts index 26a6d848d11d..f24cb2b394f7 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconDatabaseCache.ts @@ -1,5 +1,5 @@ import { - createProjectFaviconCache, + createProjectFaviconCache as createSharedProjectFaviconCache, createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, @@ -78,8 +78,13 @@ export async function downscaleProjectFavicon( throw new Error("Project icon thumbnail exceeds the cache limit."); } -/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ -export const projectFaviconCache = createProjectFaviconCache({ +/** + * The database-backed project favicon cache. Named apart from the shared + * `createProjectFaviconCache` factory it calls and from the in-flight request + * registry in `projectFaviconRequests`. Rows live in `client_cache` so + * Settings → Client storage counts and clears them. + */ +export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({ storage: { list: () => runDatabase((database) => diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconRequests.test.ts similarity index 98% rename from apps/mobile/src/components/projectFaviconCache.test.ts rename to apps/mobile/src/lib/projectFaviconRequests.test.ts index d0582a8b5f5b..540a45473926 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.test.ts @@ -6,9 +6,9 @@ import { hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, -} from "./projectFaviconCache"; +} from "./projectFaviconRequests"; -describe("project favicon cache", () => { +describe("project favicon requests", () => { it("ignores callbacks from a superseded URL", () => { const cacheKey = "environment-1:/workspace:v1-favicon.svg"; const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconRequests.ts similarity index 87% rename from apps/mobile/src/components/projectFaviconCache.ts rename to apps/mobile/src/lib/projectFaviconRequests.ts index da77d7613f2d..202142d175e1 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconRequests.ts @@ -1,3 +1,11 @@ +/** + * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with + * mounted views per cache key, keeps one of them current, and remembers which + * keys have already loaded so superseded or already-shown loads settle without + * churn. This is not request coalescing — each mount still issues its own + * load — and it is not the cache; persistence lives in + * `projectFaviconDatabaseCache`. + */ export interface ProjectFaviconRequest { readonly cacheKey: string; readonly faviconUrl: string; diff --git a/apps/mobile/src/lib/projectIcon.ts b/apps/mobile/src/lib/projectIcon.ts new file mode 100644 index 000000000000..885c583aa543 --- /dev/null +++ b/apps/mobile/src/lib/projectIcon.ts @@ -0,0 +1,81 @@ +import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; + +export type ProjectIconGlyph = + | { readonly kind: "emoji"; readonly emoji: string } + | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; + +/** + * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining + * marks are folded into their base character instead of full grapheme segmentation. + */ +export function countGlyphs(text: string): number { + return Array.from(text.replace(/\p{M}/gu, "")).length; +} + +/** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ +export function projectMonogram(projectName: string): string { + const words = + projectName + .normalize("NFKC") + .trim() + .match(/[\p{L}\p{N}]+/gu) ?? []; + const firstWord = words[0]; + if (!firstWord) return "PR"; + + const glyphs = Array.from(firstWord); + const first = glyphs[0] ?? "P"; + const second = + glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? + (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? + first; + return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); +} + +/** + * Picks what mobile draws for an assigned project icon. Mobile does not bundle + * the Lucide set, so a Lucide override keeps its color and falls back to the + * project's monogram instead of the folder glyph. + */ +export function resolveProjectIconGlyph( + projectIcon: ProjectIconOverride | null | undefined, + projectTitle: string, +): ProjectIconGlyph | null { + switch (projectIcon?.kind) { + case "emoji": + return { kind: "emoji", emoji: projectIcon.emoji }; + case "monogram": + return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; + case "lucide": + return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; + case undefined: + return null; + } +} + +const PROJECT_ICON_COLOR_CLASSES: Record< + ProjectIconColor, + { readonly text: string; readonly background: string } +> = { + gray: { text: "text-gray-500", background: "bg-gray-500/15" }, + red: { text: "text-red-500", background: "bg-red-500/15" }, + orange: { text: "text-orange-500", background: "bg-orange-500/15" }, + amber: { text: "text-amber-500", background: "bg-amber-500/15" }, + yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, + lime: { text: "text-lime-500", background: "bg-lime-500/15" }, + green: { text: "text-green-500", background: "bg-green-500/15" }, + emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, + teal: { text: "text-teal-500", background: "bg-teal-500/15" }, + cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, + sky: { text: "text-sky-500", background: "bg-sky-500/15" }, + blue: { text: "text-blue-500", background: "bg-blue-500/15" }, + indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, + violet: { text: "text-violet-500", background: "bg-violet-500/15" }, + purple: { text: "text-purple-500", background: "bg-purple-500/15" }, + fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, + pink: { text: "text-pink-500", background: "bg-pink-500/15" }, + rose: { text: "text-rose-500", background: "bg-rose-500/15" }, +}; + +export function projectIconColorClassNames(color: ProjectIconColor) { + return PROJECT_ICON_COLOR_CLASSES[color]; +} diff --git a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs new file mode 100644 index 000000000000..8d8017f28f83 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs @@ -0,0 +1,183 @@ +"use strict"; + +// Fixture for uniwind-platform-variants.test.ts. Run as a plain Node process +// (`node uniwind-platform-variants.fixture.cjs `) so it executes the +// real compiler with no test-runner transforms in the way. Prints one JSON +// object to stdout describing what each platform bundle compiled. +// +// It reproduces what the Metro transformer does for `global.css`: compile the +// Tailwind entry, then run uniwind's CSS processor and stylesheet serializer +// once per platform. Loading `dist/common` needs a small custom loader because +// uniwind declares `"type": "module"` over that CommonJS output and its +// internal `@/...` import alias only exists at uniwind's own build time; the +// loader hands unmodified file bytes to V8 and resolves those aliases the same +// way, so this runs the exact compiled code the Metro transformer bundles. +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +function createUniwindCompilerLoader(uniwindPackageJson) { + const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson)); + const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json")); + const cache = new Map(); + + const load = (absoluteFileWithoutExtension) => { + const resolved = [ + absoluteFileWithoutExtension, + `${absoluteFileWithoutExtension}.js`, + path.join(absoluteFileWithoutExtension, "index.js"), + ].find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }); + if (resolved === undefined) { + throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`); + } + const cached = cache.get(resolved); + if (cached !== undefined) return cached; + + const cjsModule = { exports: {} }; + + const localRequire = (request) => { + if (request.startsWith("@/")) { + return load(path.join(uniwindRoot, "dist/common", request.slice(2))); + } + if (request.startsWith(".")) { + return load(path.resolve(path.dirname(resolved), request)); + } + // uniwind's own dependencies (lightningcss, culori, ...) resolve through + // the package's dependency tree, like they do inside the Metro bundle. + return uniwindRequire(request); + }; + + const code = fs.readFileSync(resolved, "utf8"); + new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports); + cache.set(resolved, cjsModule.exports); + return cjsModule.exports; + }; + + const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request)); + return { loadBundler, uniwindRequire, uniwindRoot }; +} + +async function main() { + const fixtureDir = fs.realpathSync(path.resolve(process.argv[2])); + const uniwindPackageJson = require.resolve("uniwind/package.json", { + paths: [fixtureDir], + }); + const { loadBundler, uniwindRequire, uniwindRoot } = + createUniwindCompilerLoader(uniwindPackageJson); + const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js"); + const { addMetaToStylesTemplate } = loadBundler( + "bundler/css-processor/addMetaToStylesTemplate.js", + ); + const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js"); + + const { compile } = uniwindRequire("@tailwindcss/node"); + const { Scanner } = uniwindRequire("@tailwindcss/oxide"); + const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), { + base: fixtureDir, + onDependency: () => {}, + }); + const scanner = new Scanner({ + sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }], + }); + const tailwindCSS = await compiler.build(scanner.scan()); + + const platforms = {}; + // Every ios:/android: utility class Tailwind generated for the fixture. + const compiledGuardedClasses = {}; + for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) { + compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true; + } + const platformBlock = (name) => + new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? ""; + const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length; + const tailwindChecks = { + iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length, + androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length, + iosUtilities: utilityCountIn(platformBlock("ios")), + androidUtilities: utilityCountIn(platformBlock("android")), + }; + for (const platform of ["ios", "android"]) { + // Same config the app's withUniwindConfig produces (polyfills.rem: 14). + const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } }; + const processor = new ProcessorBuilder(bundlerConfig); + processor.transform(tailwindCSS); + const compiled = addMetaToStylesTemplate(processor, platform); + const payload = compileNativeCSS(bundlerConfig, tailwindCSS); + const styles = {}; + for (const [className, entries] of Object.entries(compiled)) { + styles[className] = entries.map( + ({ native, minWidth, maxWidth, active, focus, disabled, dataAttributes }) => ({ + native, + minWidth, + maxWidth, + active: active ?? null, + focus: focus ?? null, + disabled: disabled ?? null, + dataAttributes: dataAttributes ?? null, + }), + ); + } + platforms[platform] = { + styles, + payloadIncludesAllCompiled: Object.keys(compiled).every((className) => + payload.includes(`"${className}"`), + ), + // A utility dropped from this platform's stylesheet must not appear + // anywhere in the payload the bundle embeds for this platform. + payloadLeaks: Object.keys(compiledGuardedClasses) + .filter((className) => compiled[className] === undefined) + .filter((className) => payload.includes(`"${className}"`)), + }; + } + + // Exercise the real Metro transformer entry point (the shipped + // `dist/metro/transformer.cjs`, not a copy) with a stubbed downstream worker, + // to check what the global.css virtual module actually becomes — including + // the native styles fingerprint that lets dev reloads skip reinitializing. + const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs"); + let reinitCode = ""; + const Module = require("node:module"); + const origLoad = Module._load; + Module._load = function (request, ...rest) { + if (request === "metro-transform-worker") { + return { + transform: async (_config, _root, _file, data) => { + reinitCode = data.toString(); + return { output: [{ data: {} }] }; + }, + }; + } + return origLoad.call(this, request, ...rest); + }; + process.chdir(fixtureDir); + try { + const { transform } = require(transformerPath); + await transform( + { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } }, + fixtureDir, + "global.css", + Buffer.from(""), + { platform: "ios", type: "module" }, + ); + } finally { + Module._load = origLoad; + } + const transformerCheck = { + reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"), + fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode), + themesArg: reinitCode.includes("['light', 'dark']"), + }; + + process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms })); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts new file mode 100644 index 000000000000..12fa86b33c08 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -0,0 +1,253 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +// Regression guard for the uniwind platform-variant leak (audit #15 / #13161): +// Tailwind groups every `ios:` (or `android:`) utility into one shared +// `@media ios { ... }` block, and uniwind's CSS processor used to drop the +// block's media queries after its first nested rule. Everything past the first +// utility compiled unguarded and shipped to both platforms. These tests run +// the installed (patched) uniwind compiler over real Tailwind output, per +// platform, and assert what each bundle receives. The compiler itself runs in +// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no +// test-runner module transforms sit between the test and the shipped code. + +interface CompiledStyle { + native: boolean; + minWidth: number; + maxWidth: number; + active: boolean | null; + focus: boolean | null; + disabled: boolean | null; + dataAttributes: Record | null; +} + +interface FixtureOutput { + tailwindChecks: { + iosBlocks: number; + androidBlocks: number; + iosUtilities: number; + androidUtilities: number; + }; + transformerCheck: { + reinitPayload: boolean; + fingerprintArg: boolean; + themesArg: boolean; + }; + platforms: Record< + string, + { + styles: Record; + payloadIncludesAllCompiled: boolean; + payloadLeaks: string[]; + } + >; +} + +// Mirrors the classNames the audits flagged: multiple platform utilities per +// block (only the first one used to keep its guard), a base utility overridden +// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:` +// font families (worktree-setup-card). +const FIXTURE_SOURCE = `export const Probe = () => ( +
+); +`; + +const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; +const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; +const SHARED_CLASSES = ["flex-1", "pt-12"]; +const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; + +const runFixture = (globalCss: string, probeSource: string): FixtureOutput => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-")); + try { + // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app. + NodeFS.symlinkSync( + NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)), + NodePath.join(tempDir, "node_modules"), + "dir", + ); + NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss); + NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource); + + const fixture = NodeURL.fileURLToPath( + new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url), + ); + const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 60_000, + }); + // The fixture's compiler may log warnings to stdout before the JSON payload. + const start = stdout.indexOf('{"tailwindChecks"'); + if (start === -1) { + throw new Error(`Fixture produced no JSON:\n${stdout}`); + } + return JSON.parse(stdout.slice(start)) as FixtureOutput; + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } +}; + +describe("uniwind platform variants compile per platform", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE); + }, 60_000); + + it("compiles the fixture utilities into shared platform blocks", () => { + // Without this grouping the leak could not happen and the assertions below + // would prove nothing. + expect(output.tailwindChecks).toEqual({ + iosBlocks: 1, + androidBlocks: 1, + iosUtilities: 3, + androidUtilities: 3, + }); + }); + + it("keeps ios: utilities out of the android bundle and guards them on ios", () => { + for (const className of IOS_CLASSES) { + expect( + output.platforms.android?.styles[className], + `android has ${className}`, + ).toBeUndefined(); + expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined(); + expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true); + } + }); + + it("keeps android: utilities out of the ios bundle and guards them on android", () => { + for (const className of ANDROID_CLASSES) { + expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined(); + expect( + output.platforms.android?.styles[className], + `android lacks ${className}`, + ).toBeDefined(); + expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe( + true, + ); + } + }); + + it("keeps shared and responsive utilities usable on both platforms", () => { + for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles[className]; + expect(styles, `${platform} lacks ${className}`).toBeDefined(); + expect(styles?.every((style) => !style.native)).toBe(true); + } + } + // The leak hid a second regression: only the first utility of a width + // block kept its breakpoint, so the second applied at every screen size. + for (const platform of ["ios", "android"]) { + for (const className of RESPONSIVE_CLASSES) { + const style = output.platforms[platform]?.styles[className]?.at(-1); + expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0); + } + } + }); + + it("serializes every compiled style into the platform payload and leaks nothing", () => { + for (const platform of ["ios", "android"]) { + expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true); + // A platform utility dropped from this platform's stylesheet must not + // survive anywhere in the payload this platform bundles. + expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]); + } + }); +}); + +// The media-query fix must not lose the surrounding rule context: Tailwind +// also emits media rules *nested inside a class rule* (`@utility` bodies with +// `@variant`, nested breakpoints), and those declarations belong to the +// enclosing class. These expectations match the pre-fix compiler output for +// the same inputs, captured against the unpatched package. +describe("uniwind keeps media rules nested inside class rules attached to the class", () => { + let nested: FixtureOutput; + + beforeAll(() => { + nested = runFixture( + [ + '@import "tailwindcss";', + '@import "uniwind";', + "", + ".container-x {", + " width: 100%;", + " @media (width >= 40rem) {", + " max-width: 40rem;", + " }", + "}", + "", + "@utility foo-x {", + " padding: 2px;", + " @variant ios {", + " padding: 3px;", + " }", + "}", + "", + ].join("\n"), + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps the nested breakpoint entry of a nested media rule", () => { + for (const platform of ["ios", "android"]) { + const entries = nested.platforms[platform]?.styles["container-x"]; + expect(entries, `${platform} lacks container-x`).toBeDefined(); + expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true); + expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true); + } + }); + + it("keeps @variant declarations inside @utility on their platform", () => { + const iosEntries = nested.platforms.ios?.styles["foo-x"]; + expect(iosEntries?.some((style) => !style.native)).toBe(true); + expect(iosEntries?.some((style) => style.native)).toBe(true); + + const androidEntries = nested.platforms.android?.styles["foo-x"]; + expect(androidEntries?.some((style) => !style.native)).toBe(true); + expect(androidEntries?.some((style) => style.native)).toBe(false); + }); +}); + +// The patch file carries three independent uniwind fixes (state/data selector +// variants, the Metro native-styles fingerprint, and this media-query scoping +// fix). Regenerating it for one fix must not silently drop the others — this +// suite exercises the shipped transformer and compiler for the other two. +describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture( + '@import "tailwindcss";\n@import "uniwind";\n', + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps state and data variants conditioned, and rejects unsupported compounds", () => { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles; + expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true); + expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true); + expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe( + true, + ); + // `[aria-disabled="true"]` is not expressible at runtime; emitting it + // unconditionally (the pre-#9355 behavior) styles the element always. + expect(styles).not.toHaveProperty("aria-disabled:text-red-500"); + } + }); + + it("emits the global.css virtual module with a native styles fingerprint", () => { + expect(output.transformerCheck).toEqual({ + reinitPayload: true, + fingerprintArg: true, + themesArg: true, + }); + }); +}); diff --git a/apps/mobile/src/lib/useDelayedStatus.ts b/apps/mobile/src/lib/useDelayedStatus.ts new file mode 100644 index 000000000000..a96edfdf23df --- /dev/null +++ b/apps/mobile/src/lib/useDelayedStatus.ts @@ -0,0 +1,18 @@ +import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; +import { useEffect, useState } from "react"; + +/** + * Returns `value` only once it has lasted past the show delay, then holds it + * for a minimum time, so a short status never flashes. `key` is what the + * status belongs to (for example a thread). A new key drops it at once. + * Web has the same hook. + */ +export function useDelayedStatus(key: string, value: A | null): A | null { + const [shown, setShown] = useState | null>(null); + const [status] = useState(() => createDelayedStatus(setShown)); + useEffect(() => () => status.dispose(), [status]); + useEffect(() => { + status.update(key, value); + }, [status, key, value]); + return shown?.key === key ? shown.value : null; +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 508630b1e581..9514efdff424 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -33,14 +33,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - /** - * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has - * no client-settings sync, so the legacy grouped thread list is opted into - * per device. Deliberately a fresh key (was `threadListV2Enabled`, an - * opt-out): sanitizing drops the old key, so every device resets to the - * default flat list — see `resolveThreadListV2Enabled`. - */ - readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; /** Model favorites belong to this device, like the web client setting. */ @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { composerEnterBehavior?: ComposerEnterBehavior; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.legacyThreadListEnabled === "boolean") { - preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; - } if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 15cbd1d9a89f..b2fb759c5680 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,7 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ - imageCache: projectFaviconCache, + imageCache: projectFaviconDatabaseCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c210c54f2fdd..0060e6ab0789 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => Effect.promise(() => scope.type === "all" - ? projectFaviconCache.clearAll() - : projectFaviconCache.clearEnvironment(scope.environmentId), + ? projectFaviconDatabaseCache.clearAll() + : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId), ).pipe( Effect.andThen(MobileDatabase), Effect.flatMap((database) => diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 352367c09a15..dec31f9ce039 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, @@ -20,7 +21,6 @@ import { composerDraftsAtom, ensureComposerDraftsLoaded, flushComposerDrafts, - retainComposerAttachmentFileForPreview, setComposerDraftAttachmentUpload, } from "./use-composer-drafts"; import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.test.ts b/apps/mobile/src/state/legacy-plan-mode.test.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.test.ts rename to apps/mobile/src/state/legacy-plan-mode.test.ts diff --git a/apps/mobile/src/features/threads/legacy-plan-mode.ts b/apps/mobile/src/state/legacy-plan-mode.ts similarity index 100% rename from apps/mobile/src/features/threads/legacy-plan-mode.ts rename to apps/mobile/src/state/legacy-plan-mode.ts diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3d2231fb3ac3..c53dc34e7df4 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -26,7 +26,7 @@ import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index d49c26a40a44..c5895d353fef 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9f5dad4d5600..fa6b2f303f70 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -179,7 +179,6 @@ import { removeComposerDraftsForEnvironment, replaceComposerDraftAttachments, resetComposerDraftsLoadState, - retainComposerAttachmentFileForPreview, restoreComposerDraftSnapshotState, restoreCloudComposerDrafts, retargetNewTaskDraft, @@ -194,6 +193,7 @@ import { undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; +import { retainComposerAttachmentFileForPreview } from "../lib/composerAttachmentPreviewRetention"; const DRAFT: ComposerDraft = { text: "hello", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..de886d54fe91 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -35,8 +35,11 @@ import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import { composerAttachmentFileReferenceKey, isComposerAttachmentFileRetained, - retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; +import { + registerComposerAttachmentUnusedHandler, + retainComposerAttachmentFileForPreview, +} from "../lib/composerAttachmentPreviewRetention"; import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; @@ -957,14 +960,14 @@ export function scheduleUnusedComposerAttachmentCleanup( }); } -/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ -export function retainComposerAttachmentFileForPreview( - attachment: FileBackedComposerAttachment, -): () => void { - return retainComposerAttachmentFile(attachment.fileUri, () => { - scheduleUnusedComposerAttachmentCleanup([attachment]); - }); -} +/** + * Owner-side cleanup hook for the shared preview-retention helper: releasing + * the last preview/upload lease retries the unused-file sweep. Registered here + * because this module owns the draft and outbox references the sweep reads. + */ +registerComposerAttachmentUnusedHandler((attachment) => { + scheduleUnusedComposerAttachmentCleanup([attachment]); +}); function schedulePersistComposerState(): void { if (persistTimer !== null) { diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 1664dc7461aa..ce309f5b06fd 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useNavigation } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -111,6 +112,7 @@ export function useRemoteConnectionStatus() { export function useRemoteConnections() { const controller = useConnectionController(); + const navigation = useNavigation(); const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); @@ -171,22 +173,37 @@ export function useRemoteConnections() { if (!environment) { return; } + const remove = { + text: "Remove", + style: "destructive", + onPress: () => { + void controller.removeEnvironment(environmentId); + }, + } as const; + // Removing a T3 Connect environment here leaves its account registration + // and host space, so point to where it can be deregistered. + if (environment.isRelayManaged) { + Alert.alert( + "Remove from this device?", + `Forget ${environment.environmentLabel} and its cached threads on this device.\n\nIt stays on your T3 Connect account and keeps its host space. Deregister it under T3 Account → T3 Connect to free it.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open T3 Account", + onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }), + }, + remove, + ], + ); + return; + } Alert.alert( "Remove from this device?", `Forget ${environment.environmentLabel} and its cached threads on this device. Switch it off instead to keep it saved.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => { - void controller.removeEnvironment(environmentId); - }, - }, - ], + [{ text: "Cancel", style: "cancel" }, remove], ); }, - [connectedEnvironments, controller], + [connectedEnvironments, controller, navigation], ); return { diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..051fe543ea2b 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -30,7 +30,7 @@ import { uuidv4 } from "../lib/uuid"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { isModelSelectionUnavailable } from "../lib/modelOptions"; -import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +import { resolveProviderInteractionMode } from "./legacy-plan-mode"; import { convertPastedImagesToAttachments, createPastedTextComposerAttachment, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..022c0ad6addb 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,92 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + +/** + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. + */ +function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + !isRpcClientDecodeDefect(input.error) && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, @@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d922eb7f6d5c..108afbbc9416 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -74,6 +74,7 @@ function threadDetailToShell( settledAt: thread.settledAt, unsettledAt: thread.unsettledAt, activeOrderKey: thread.activeOrderKey, + autoSettleDisabledAt: thread.autoSettleDisabledAt, pinnedAt: thread.pinnedAt, pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx index c8cde5d444fe..a32454e5ff83 100644 --- a/apps/mobile/src/widgets/SubscriptionUsage.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -235,7 +235,11 @@ function SubscriptionUsage( spacing={accessory || dense ? 2 : 6} modifiers={props.url ? [widgetURL(props.url)] : []} > - {compact ? ( + {providers.length === 0 ? ( + + No subscription limits available. + + ) : compact ? ( {columns} diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx index 84dce5d515ec..4a805b48ce22 100644 --- a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import * as Linking from "expo-linking"; import { useEffect } from "react"; +import { Platform } from "react-native"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations } from "../state/presentation"; import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; @@ -13,6 +14,8 @@ const snapshotAtom = Atom.make((get) => buildSubscriptionUsageSnapshot( get(environmentPresentations.presentationsAtom), Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), + // Android scrolls the full list; iOS stores a bounded widget timeline. + Platform.OS === "android" ? Infinity : 6, ), ).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index 65d4c9924f66..8ab3d6191ef5 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -65,18 +65,63 @@ describe("subscription widget snapshots", () => { expect(snapshot.url).toBe(deepLink); expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); }); - it("clears data after removing environments and hides disabled providers", () => { - expect( - buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every( - (p) => p.windows.length === 0, - ), - ).toBe(true); + it("clears data after removing environments", () => { + expect(buildSubscriptionUsageSnapshot(new Map(), deepLink).providers).toEqual([]); + }); + it.each<{ name: string; overrides: Partial }>([ + { name: "disabled", overrides: { enabled: false } }, + { + name: "missing", + overrides: { installed: false, status: "error", usageLimits: undefined }, + }, + { + name: "API-key", + overrides: { + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }, + }, + ])("hides $name providers", ({ overrides }) => { expect( - buildSubscriptionUsageSnapshot( - presentations([provider({ enabled: false })]), - deepLink, - ).providers.every((p) => p.windows.length === 0), - ).toBe(true); + buildSubscriptionUsageSnapshot(presentations([provider(overrides)]), deepLink).providers, + ).toEqual([]); + }); + it.each([ + { name: "Codex", driver: "codex" }, + { name: "Claude", driver: "claudeAgent" }, + ])("only shows $name when $name and OpenCode are configured", ({ name, driver }) => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + }), + provider({ + instanceId: ProviderInstanceId.make("opencode"), + driver: ProviderDriverKind.make("opencode"), + usageLimits: undefined, + }), + ]), + deepLink, + ); + expect(snapshot.providers).toHaveLength(1); + expect(snapshot.providers[0]).toMatchObject({ + name, + totalWindows: 1, + windows: [{ remaining: 60 }], + }); + expect(subscriptionUsageTimeline(snapshot, now + 15 * 60_000)[0]?.props.providers).toEqual([ + expect.objectContaining({ name, totalWindows: 0, windows: [] }), + ]); + }); + it("keeps an enabled provider visible before its first usage read", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: undefined })]), + deepLink, + ); + expect(snapshot.checkedAt).toBe(0); + expect(snapshot.providers).toEqual([ + { name: "Codex", detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }, + ]); }); it("uses upstream deduplication for a native account also present in a proxy hub", () => { const input = new Map([ @@ -151,6 +196,21 @@ describe("subscription widget snapshots", () => { expect(snapshot.providers[0]?.totalWindows).toBe(20); expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); }); + it("includes every limit for the scrollable Android widget", () => { + const windows = Array.from({ length: 20 }, (_, index) => ({ + ...window, + id: `${index}`, + usedPercent: index * 5, + })); + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows } })]), + deepLink, + Infinity, + ); + expect(snapshot.providers[0]?.windows.map((window) => window.remaining)).toEqual( + Array.from({ length: 20 }, (_, index) => 5 + index * 5), + ); + }); it("marks unknown or distant reset times stale after fifteen minutes", () => { const snapshot = buildSubscriptionUsageSnapshot( presentations([ diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index 826124e1053f..44f2c6ef4bed 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -46,6 +46,8 @@ export function createWidgetRefresher(refresh: (id: Id) => Promise) function subscriptionUsageProps( accounts: readonly LimitAccount[], now: number, + configuredDrivers: ReadonlySet, + maxWindowsPerProvider: number, ): SubscriptionUsageSnapshot { const pools = collectLimitPools(accounts, now); const checked = accounts @@ -53,57 +55,68 @@ function subscriptionUsageProps( .map((account) => Date.parse(account.limits.checkedAt)); return { checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0, - providers: (["codex", "claudeAgent"] as const).map((driver) => { - const pool = pools.find((candidate) => candidate.driver === driver); - const name = driver === "codex" ? "Codex" : "Claude"; - if (!pool) - return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }; - const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); - const expiresAt = Math.min( - checkedAt + SNAPSHOT_MAX_AGE, - ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), - ); - const fresh = Number.isFinite(expiresAt) && expiresAt > now; - const sortedWindows = [...pool.windows].sort( - (a, b) => a.remainingPercent - b.remainingPercent, - ); - // Keep a session and weekly limit when scoped limits fill the storage budget. - const selectedWindows = [ - ...new Set([ - sortedWindows.find((window) => window.kind === "session"), - sortedWindows.find((window) => window.kind === "weekly"), - ...sortedWindows, - ]), - ] - .filter((window) => window !== undefined) - .slice(0, 6) - .sort((a, b) => a.remainingPercent - b.remainingPercent); - return { - name, - detail: !fresh - ? "Open T3 to refresh" - : pool.accounts.length > 1 - ? `${pool.accounts.length} accounts · pooled` - : "Subscription remaining", - expiresAt: fresh ? expiresAt : 0, - totalWindows: fresh ? pool.windows.length : 0, - windows: fresh - ? selectedWindows.map((window) => ({ - kind: window.kind, - label: window.label, - remaining: Math.round(window.remainingPercent), - reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })}` - : "Reset time unavailable", - })) - : [], - }; - }), + providers: (["codex", "claudeAgent"] as const) + .filter( + (driver) => + configuredDrivers.has(driver) || accounts.some((account) => account.driver === driver), + ) + .map((driver) => { + const pool = pools.find((candidate) => candidate.driver === driver); + const name = driver === "codex" ? "Codex" : "Claude"; + if (!pool) + return { + name, + detail: "No limits available", + windows: [], + expiresAt: 0, + totalWindows: 0, + }; + const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); + const expiresAt = Math.min( + checkedAt + SNAPSHOT_MAX_AGE, + ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ); + const fresh = Number.isFinite(expiresAt) && expiresAt > now; + const sortedWindows = [...pool.windows].sort( + (a, b) => a.remainingPercent - b.remainingPercent, + ); + // Keep a session and weekly limit when scoped limits fill the storage budget. + const selectedWindows = [ + ...new Set([ + sortedWindows.find((window) => window.kind === "session"), + sortedWindows.find((window) => window.kind === "weekly"), + ...sortedWindows, + ]), + ] + .filter((window) => window !== undefined) + .slice(0, maxWindowsPerProvider) + .sort((a, b) => a.remainingPercent - b.remainingPercent); + return { + name, + detail: !fresh + ? "Open T3 to refresh" + : pool.accounts.length > 1 + ? `${pool.accounts.length} accounts · pooled` + : "Subscription remaining", + expiresAt: fresh ? expiresAt : 0, + totalWindows: fresh ? pool.windows.length : 0, + windows: fresh + ? selectedWindows.map((window) => ({ + kind: window.kind, + label: window.label, + remaining: Math.round(window.remainingPercent), + reset: window.resets[0] + ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}` + : "Reset time unavailable", + })) + : [], + }; + }), }; } @@ -111,9 +124,31 @@ function subscriptionUsageProps( export function buildSubscriptionUsageSnapshot( presentations: LimitPresentations, url: string, + maxWindowsPerProvider = 6, ): SubscriptionUsageSnapshot { // Freshness is evaluated at publication/render time, not on unrelated config emissions. - return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url }; + const configuredDrivers = new Set( + [...presentations.values()].flatMap((presentation) => + (presentation.serverConfig?.providers ?? []) + // Servers report default-enabled drivers even when their CLI is missing. + .filter( + (provider) => + provider.enabled && + provider.installed && + provider.usageLimits?.unavailable?.reason !== "unsupported", + ) + .map((provider) => provider.driver), + ), + ); + return { + ...subscriptionUsageProps( + collectLimitAccounts(presentations), + 0, + configuredDrivers, + maxWindowsPerProvider, + ), + url, + }; } export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0df54be2f701..5e3b4c6e4afe 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -26,6 +26,7 @@ import * as Tracer from "effect/Tracer"; import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts"; import { TextGeneration } from "../src/textGeneration/TextGeneration.ts"; +import * as TerminalManager from "../src/terminal/Manager.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; @@ -340,6 +341,7 @@ export const makeOrchestrationIntegrationHarness = ( tryHandlePromptCommand: () => Effect.succeed(false), }), ), + Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), @@ -418,6 +420,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => Effect.void, }), ), diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 77173af1b377..b483a3f99f8e 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -100,7 +100,7 @@ const startupDependencies = Layer.mergeAll( Layer.succeed(ServiceLauncherClient.ServiceLauncherClient, { managed: false, requestUpdate: () => Effect.die("unused"), - prepareTrial: Effect.sync(() => undefined), + prepareTrial: Effect.undefined, }), Layer.succeed( HttpServer.HttpServer, diff --git a/apps/server/package.json b/apps/server/package.json index 82df7c8930bf..a4f13ac07769 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.53", + "version": "0.0.54", "license": "MIT", "repository": { "type": "git", @@ -27,10 +27,13 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", "diff": "8.0.3", "effect": "catalog:", - "node-pty": "^1.1.0", + "jose": "catalog:", + "node-pty": "^1.2.0-beta.15", + "proper-lockfile": "4.1.2", "stream-chain": "^4.2.5", "stream-json": "3.6.0", "yaml": "catalog:", @@ -44,6 +47,7 @@ "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/node": "catalog:", + "@types/proper-lockfile": "^4.1.4", "@types/yauzl": "^3.4.0", "effect-acp": "workspace:*", "effect-codex-app-server": "workspace:*", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9fbdeee03c86..4865354e41f5 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; +const emitBackgroundToolDuringAnswer = + process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; @@ -619,6 +621,12 @@ const program = Effect.gen(function* () { Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); promptCount += 1; + if ( + process.env.T3_ACP_CRASH_PROMPT === "1" && + request.prompt.some((part) => part.type === "text" && part.text === "crash now") + ) { + return yield* Effect.sync(() => process.exit(23)); + } if (completeFirstPromptOnCancel && promptCount === 1) { yield* agent.client.sessionUpdate({ @@ -941,6 +949,47 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitBackgroundToolDuringAnswer) { + // A command backgrounded earlier reports progress and then finishes + // while the next answer is still streaming. + const toolCallId = "background-1"; + const say = (text: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + const progress = (status: "in_progress" | "completed", stdout: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status, + rawOutput: { stdout }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 3 && echo done" }, + }, + }); + yield* say("| a | b |\n|---|---|\n| 1 "); + yield* progress("in_progress", "."); + yield* say("| x |\n"); + yield* progress("completed", "done"); + yield* say("| 2 | y |\n"); + // Agents can repeat a terminal update after the call finished. + yield* progress("completed", "done"); + yield* say("| 3 | z |"); + return { stopReason: "end_turn" }; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..4c6e04bb510e 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; +import * as ServerSettings from "../src/serverSettings.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; @@ -155,7 +156,8 @@ await Effect.runPromise( GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, - BitbucketApi.layer, + // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables. + BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())), ), ), Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index e1f6633fbeca..8dbd1c755966 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -190,7 +190,7 @@ const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), @@ -213,9 +213,9 @@ const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWor const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ - WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + WorkspacePathOutsideRootError: () => Effect.succeedNone, }), ); if (Option.isNone(resolved)) return null; diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index 89a6d0636012..34299f95142f 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -43,10 +43,10 @@ function appFromCacheKey(key: string): ToolActivityNativeAppReference { const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { const fileSystem = yield* FileSystem.FileSystem; const info = yield* fileSystem.stat(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + error.reason._tag === "NotFound" ? Effect.succeedNone : Effect.fail(error), }), ); return Option.isSome(info) && info.value.type === "File" ? filePath : null; diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 9a093be41ca4..beb07627c66a 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -3,7 +3,6 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Queue from "effect/Queue"; import * as TestClock from "effect/testing/TestClock"; @@ -47,10 +46,10 @@ const makePairingGrantStoreTestLayer = ( AuthPairingLinks.AuthPairingLinkRepository, AuthPairingLinks.AuthPairingLinkRepository.of({ create: () => Effect.void, - consumeAvailable: () => Effect.succeed(Option.none()), + consumeAvailable: () => Effect.succeedNone, listActive: () => Effect.succeed([]), revoke: () => Effect.succeed(false), - getByCredential: () => Effect.succeed(Option.none()), + getByCredential: () => Effect.succeedNone, ...overrides, }), ), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 90dc5c0fffd1..9d6ff75f3bb9 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -37,6 +37,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope, [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptReconnectProfile]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptImportProfile]: AuthOrchestrationOperateScope, + [WS_METHODS.chatGptHandoffSubscribe]: AuthOrchestrationOperateScope, + [WS_METHODS.codexAuthCallbackSubscribe]: AuthOrchestrationOperateScope, + [WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope, [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index e936a1f85c99..1ee11d3e198e 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -138,6 +138,8 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => export class ServerSecretStore extends Context.Service< ServerSecretStore, { + /** File-backed stores expose their directory for cross-process credential leases. */ + readonly directory?: string; readonly get: (name: string) => Effect.Effect, SecretStoreError>; readonly set: (name: string, value: Uint8Array) => Effect.Effect; readonly create: (name: string, value: Uint8Array) => Effect.Effect; @@ -174,7 +176,7 @@ export const make = Effect.gen(function* () { Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : Effect.fail( new SecretStoreReadError({ resource: `secret ${name}`, @@ -303,6 +305,7 @@ export const make = Effect.gen(function* () { ); return ServerSecretStore.of({ + directory: serverConfig.secretsDir, get, set, create, diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 43f90e440915..5e026f584273 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -18,6 +18,9 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; +/** Secret store name prefix of DPoP replay markers. The server prunes expired ones. */ +export const DPOP_REPLAY_MARKER_PREFIX = "dpop-proof-"; + export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => { switch (code) { case "time_window": @@ -96,7 +99,7 @@ export const verifyRequestDpopProof = (input: { ); yield* secretStore .create( - `dpop-proof-${replayKey}`, + `${DPOP_REPLAY_MARKER_PREFIX}${replayKey}`, new TextEncoder().encode( [ `thumbprint=${result.thumbprint}`, @@ -115,7 +118,7 @@ export const verifyRequestDpopProof = (input: { "environment.dpop.failure_code": mapped.dpopFailureReason, }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 3d53ee088376..793f86349650 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -5,7 +5,6 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Etag from "effect/unstable/http/Etag"; @@ -69,7 +68,7 @@ it.effect("sets the selected browser session cookies through the HTTP route", () Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, set: () => Effect.void, create: () => Effect.void, getOrCreateRandom: () => Effect.die("Not used by these routes."), diff --git a/apps/server/src/auth/replayMarkers.test.ts b/apps/server/src/auth/replayMarkers.test.ts new file mode 100644 index 000000000000..76c31b0162bd --- /dev/null +++ b/apps/server/src/auth/replayMarkers.test.ts @@ -0,0 +1,92 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +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 TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import { pruneExpiredReplayMarkers, REPLAY_MARKER_MAX_AGE } from "./replayMarkers.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; + +// Every secret name the server stores today. The last three stand for names +// built from an id at runtime. +const REAL_SECRET_NAMES = [ + "server-signing-key", + "asset-access-signing-key", + "cloud-cli-oauth-token", + "cloud-cli-desired-link", + "cloud-link-ed25519-key-pair", + "cloud-link-ed25519-private-key", + "cloud-link-ed25519-public-key", + "cloud-mint-ed25519-public-key", + "cloud-endpoint-runtime-config", + "cloud-endpoint-confirmed-origin", + "cloud-linked-user-id", + "cloud-relay-url", + "cloud-relay-issuer", + "cloud-relay-environment-credential", + "cloud-publish-agent-activity", + "provider-env-Y29kZXg-T1BFTkFJX0FQSV9LRVk", + "provider-auth-0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0", + "usage-limit-source-aHVi", +]; + +it.layer(NodeServices.layer)("replayMarkers", (it) => { + it.effect("prunes only replay markers older than the max age", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const now = DateTime.makeUnsafe("2026-01-01T00:00:00Z"); + const setAge = (fileName: string, age: Duration.Duration) => { + const mtime = DateTime.toDateUtc(DateTime.subtractDuration(now, age)); + return fileSystem.utimes(path.join(secretsDir, fileName), mtime, mtime); + }; + const writeAged = (name: string, age: Duration.Duration) => + secretStore + .create(name, Uint8Array.from([1])) + .pipe(Effect.andThen(setAge(`${name}.bin`, age))); + + const justExpired = Duration.sum(REPLAY_MARKER_MAX_AGE, Duration.seconds(1)); + const expiredMarkers = [ + "dpop-proof-old", + "cloud-mint-jti-old", + "cloud-mint-nonce-old", + "cloud-health-jti-old", + "cloud-health-nonce-old", + ]; + for (const name of expiredMarkers) yield* writeAged(name, justExpired); + yield* writeAged("dpop-proof-at-max-age", REPLAY_MARKER_MAX_AGE); + for (const name of REAL_SECRET_NAMES) yield* writeAged(name, Duration.days(30)); + const pendingSetFile = "dpop-proof-pending.bin.0000.tmp"; + yield* fileSystem.writeFile(path.join(secretsDir, pendingSetFile), Uint8Array.from([1])); + yield* setAge(pendingSetFile, Duration.days(30)); + yield* TestClock.setTime(DateTime.toEpochMillis(now)); + + yield* pruneExpiredReplayMarkers(); + + const remaining = yield* fileSystem.readDirectory(secretsDir); + assert.deepStrictEqual( + remaining.toSorted(), + [ + ...REAL_SECRET_NAMES.map((name) => `${name}.bin`), + "dpop-proof-at-max-age.bin", + pendingSetFile, + ].toSorted(), + ); + }).pipe( + Effect.provide( + ServerSecretStore.layer.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-replay-markers-test-" }), + ), + ), + ), + ), + ); +}); diff --git a/apps/server/src/auth/replayMarkers.ts b/apps/server/src/auth/replayMarkers.ts new file mode 100644 index 000000000000..67b56b722334 --- /dev/null +++ b/apps/server/src/auth/replayMarkers.ts @@ -0,0 +1,77 @@ +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +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 { CLOUD_REPLAY_MARKER_PREFIXES } from "../cloud/http.ts"; +import * as ServerConfig from "../config.ts"; +import { forkParked } from "../serverActivation.ts"; +import { DPOP_REPLAY_MARKER_PREFIX } from "./dpop.ts"; + +const REPLAY_MARKER_PREFIXES = [DPOP_REPLAY_MARKER_PREFIX, ...CLOUD_REPLAY_MARKER_PREFIXES]; + +/** + * How long a replay marker stays on disk. A marker only matters while its proof + * can pass the time check (about 5 minutes for DPoP, 7 for cloud proofs). After + * that, the time check rejects a replay by itself. The sweep and the time check + * both use the wall clock, so a pruned marker can let a replay through only if + * the clock moves back by almost a day, or if the filesystem stamps mtimes almost + * a day behind. Markers are files, so a restart does not reset them. + */ +export const REPLAY_MARKER_MAX_AGE = Duration.days(1); + +/** + * Deletes replay markers whose mtime is older than `REPLAY_MARKER_MAX_AGE`. + * `ServerSecretStore` saves each secret as `.bin`, so only + * `*.bin` names match. Other secrets and the `*.bin..tmp` + * files that `set` writes are never touched. + */ +export const pruneExpiredReplayMarkers = Effect.fn("replayMarkers.pruneExpired")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { secretsDir } = yield* ServerConfig.ServerConfig; + const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(REPLAY_MARKER_MAX_AGE); + const markers = (yield* fileSystem.readDirectory(secretsDir)).filter( + (name) => + name.endsWith(".bin") && REPLAY_MARKER_PREFIXES.some((prefix) => name.startsWith(prefix)), + ); + // `partition` visits every marker, so one locked file does not stop the sweep. + const [failures, removed] = yield* Effect.partition(markers, (name) => { + const markerPath = path.join(secretsDir, name); + return fileSystem.stat(markerPath).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => mtime.getTime() < cutoff) + ? fileSystem.remove(markerPath).pipe(Effect.as(true)) + : Effect.succeed(false), + ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + }); + yield* Effect.annotateCurrentSpan({ + "replay_markers.matched": markers.length, + "replay_markers.removed": removed.filter(Boolean).length, + "replay_markers.failed": failures.length, + }); + if (failures.length > 0) { + yield* Effect.logWarning("Failed to prune some replay markers", { + failed: failures.length, + cause: failures[0], + }); + } +}); + +/** Prunes expired replay markers after server activation, then every hour. */ +export const layer = Layer.effectDiscard( + forkParked( + pruneExpiredReplayMarkers().pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to prune expired replay markers", { cause }), + ), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + ), + ), +); diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index e009cf572f30..d87d8cbbb437 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -16,6 +16,7 @@ import { import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -105,7 +106,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 117c010f09fc..7877704daa33 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -25,6 +25,7 @@ import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; +import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -73,6 +74,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => officialImportCommand, sshHelperCommand, themeCommand, + traceCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 7bfd313a2103..6df7d7534130 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -84,15 +84,16 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { @@ -113,9 +114,9 @@ describe("CheckpointDiffQuery.layer", () => { }), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -201,23 +202,24 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -293,23 +295,24 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -370,23 +373,24 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -432,23 +436,24 @@ describe("CheckpointDiffQuery.layer", () => { getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 34ea7f685364..42932b927008 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; import { resolveServerConfig } from "./config.ts"; @@ -55,7 +56,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -507,7 +508,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { resolved.terminalLogsDir, resolved.attachmentsDir, resolved.worktreesDir, - path.dirname(resolved.serverLogPath), path.dirname(resolved.serverTracePath), ]) { expect(yield* fs.exists(directory)).toBe(true); @@ -666,6 +666,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-off-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + NetService.layer, + ), + ), + ); + + // The switch beats every source, including an endpoint stored in Settings. + expect(resolved.otlpTracesUrl).toBeUndefined(); + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + expect(resolved.otelEnvironment.disabled).toBe(true); + }), + ); + + it.effect("lets T3CODE_OTEL_SDK_DISABLED=false override an ambient OTEL_SDK_DISABLED=true", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-on-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otelEnvironment.disabled).toBe(false); + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + }), + ); + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => Effect.gen(function* () { const { join } = yield* Path.Path; @@ -894,4 +997,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); }), ); + + const minimalWebFlags = (baseDir: string) => ({ + mode: Option.some("web" as const), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }); + + it.effect( + "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-precedence-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + otlpMetricsUrl: "http://bootstrap:4318/v1/metrics", + // Blank, not an endpoint: it must not stand in front of Settings. + otlpLogsUrl: "", + }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + T3CODE_OTLP_HEADERS: "x-key=secret", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same + // signal, and keeps T3 Code's own headers since T3 Code still owns it. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" }); + // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins + // over the bootstrap envelope and brings the OTEL headers and protocol. + expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); + expect(resolved.otlpMetricsExport).toEqual({ + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }); + // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so + // Settings answers, and logs keep the shared headers since no OTEL + // endpoint claimed them. + expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs"); + expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" }); + }), + ); + + it.effect( + "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-off-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL still wins outright. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + // The OTEL endpoint claimed metrics and logs, so neither the bootstrap + // envelope nor Settings receives them with T3 Code's headers. + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 42cc883bc891..58bd8dce22f4 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -4,6 +4,7 @@ import { OtlpProtocol, type SignalExport, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -81,16 +82,22 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.optional, ); +// Trace file location, shared by the server and `t3 trace summary`. +export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe( + Config.option, + Config.map(Option.getOrUndefined), +); +export const traceMaxFilesConfig = Config.Int("T3CODE_TRACE_MAX_FILES").pipe( + Config.withDefault(10), +); + const EnvServerConfig = Config.all({ logLevel: Config.LogLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), traceMinLevel: Config.LogLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), traceTimingEnabled: Config.Boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), - traceFile: Config.String("T3CODE_TRACE_FILE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + traceFile: traceFileConfig, traceMaxBytes: Config.Int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), - traceMaxFiles: Config.Int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), + traceMaxFiles: traceMaxFilesConfig, traceBatchWindowMs: Config.Int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(1_000)), otlpTracesUrl: Config.String("T3CODE_OTLP_TRACES_URL").pipe( Config.option, @@ -107,7 +114,6 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.String("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -386,6 +392,8 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + const otel = yield* OtelEnvironment.load; + // T3 Code's own OTLP variables name no signal, so the one answer they give // is the answer for all three. const signalExport: SignalExport = { @@ -393,6 +401,27 @@ export const resolveServerConfig = ( headers: env.otlpHeaders, exportIntervalMs: env.otlpExportIntervalMs, }; + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: env.otlpTracesUrl, export: signalExport }, + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: env.otlpMetricsUrl, export: signalExport }, + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: env.otlpLogsUrl, export: signalExport }, + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); const config: ServerConfig.ServerConfig["Service"] = { logLevel, @@ -401,20 +430,13 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, - otlpLogsUrl: - env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, - otlpTracesExport: signalExport, - otlpMetricsExport: signalExport, - otlpLogsExport: signalExport, - otlpServiceName: env.otlpServiceName, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, + otelEnvironment: otel, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 5a66b6557db5..14a6ed640570 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -325,7 +326,7 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts index 26a0c337fb27..ab662596267a 100644 --- a/apps/server/src/cli/theme.ts +++ b/apps/server/src/cli/theme.ts @@ -260,12 +260,10 @@ const writeDefaultTheme = Effect.fn(function* (input: { // Falling through here would overwrite whatever landed in between, which // is exactly the loss this loop exists to prevent. if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { - return yield* Effect.fail( - new ThemeSettingsBusyError({ - settingsPath: input.settingsPath, - attempts: CONCURRENT_WRITE_ATTEMPTS, - }), - ); + return yield* new ThemeSettingsBusyError({ + settingsPath: input.settingsPath, + attempts: CONCURRENT_WRITE_ATTEMPTS, + }); } continue; } @@ -299,12 +297,13 @@ const publishThemeFile = Effect.fn(function* (input: { Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), ); if (info.type !== "File") { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + return yield* new ThemeFileUnreadableError({ filePath: input.filePath }); } if (Number(info.size) > MAX_THEME_FILE_BYTES) { - return yield* Effect.fail( - new ThemeFileTooLargeError({ filePath: input.filePath, limit: MAX_THEME_FILE_BYTES }), - ); + return yield* new ThemeFileTooLargeError({ + filePath: input.filePath, + limit: MAX_THEME_FILE_BYTES, + }); } // An explicit source path is the user's own input, and a symlink there is a @@ -320,17 +319,15 @@ const publishThemeFile = Effect.fn(function* (input: { ); const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES); if (raw === null) { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + return yield* new ThemeFileUnreadableError({ filePath: input.filePath }); } const decoded = decodeThemeFileJsonExit(raw); if (decoded._tag === "Failure") { - return yield* Effect.fail( - new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }), - ); + return yield* new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }); } if (!environmentThemeFileHasColors(decoded.value)) { - return yield* Effect.fail(new ThemeFileColorlessError({ filePath: input.filePath })); + return yield* new ThemeFileColorlessError({ filePath: input.filePath }); } const fileBasename = path.basename(input.filePath, ".json"); @@ -338,7 +335,7 @@ const publishThemeFile = Effect.fn(function* (input: { // The same rules the watcher applies when it reads the directory back, so a // publish cannot report success for a file that will then be skipped. if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) { - return yield* Effect.fail(new ThemeFileIdInvalidError({ themeId, filePath: input.filePath })); + return yield* new ThemeFileIdInvalidError({ themeId, filePath: input.filePath }); } const destinationPath = path.join(input.themesDir, `${themeId}.json`); @@ -479,7 +476,7 @@ const themeSetCommand = Command.make("set", { const fs = yield* FileSystem.FileSystem; const target = yield* expandHomePath(flags.theme.trim()); if (target.length === 0) { - return yield* Effect.fail(new ThemeTargetMissingError()); + return yield* new ThemeTargetMissingError(); } const paths = yield* resolveThemePaths(flags.baseDir); @@ -514,15 +511,15 @@ const themeSetCommand = Command.make("set", { revertPublish = published.revert; cleanupPublish = published.cleanup; } else if (looksLikePath) { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: target })); + return yield* new ThemeFileUnreadableError({ filePath: target }); } else if (isEnvironmentThemeId(target)) { const known = yield* resolvableThemeIds(paths.themesDir); if (!known.includes(target)) { - return yield* Effect.fail(new ThemeIdUnknownError({ themeId: target, known })); + return yield* new ThemeIdUnknownError({ themeId: target, known }); } themeId = target; } else { - return yield* Effect.fail(new ThemeIdInvalidError({ themeId: target })); + return yield* new ThemeIdInvalidError({ themeId: target }); } // set means set: if the default cannot be written, the publish that diff --git a/apps/server/src/cli/trace.test.ts b/apps/server/src/cli/trace.test.ts new file mode 100644 index 000000000000..b81a3ed53f25 --- /dev/null +++ b/apps/server/src/cli/trace.test.ts @@ -0,0 +1,100 @@ +import { assert, it } from "@effect/vitest"; + +import { makeTraceSpanSummary } from "./trace.ts"; + +const MINUTE_MS = 60_000; + +function span(name: string, durationMs: number, endMs: number, exitTag = "Success") { + return JSON.stringify({ + type: "effect-span", + name, + traceId: "trace", + spanId: "span", + durationMs, + endTimeUnixNano: String(BigInt(endMs) * 1_000_000n), + exit: { _tag: exitTag, cause: "cause" }, + }); +} + +function browserSpan(name: string, status: { code: string; message?: string }) { + return JSON.stringify({ + type: "otlp-span", + name, + durationMs: 1, + endTimeUnixNano: "1000000", + status, + }); +} + +it("reports count, rate, percentiles, and exits per span name", () => { + // Ten `refresh` spans of 1..10 ms end over minutes 0..9, and one `probe` + // span ends at minute 10, so the recorded window is 10 minutes. + const refreshes = Array.from({ length: 10 }, (_, index) => + span( + "refresh", + index + 1, + index * MINUTE_MS, + index === 0 ? "Interrupted" : index === 1 ? "Failure" : "Success", + ), + ); + const summarizer = makeTraceSpanSummary(); + [...refreshes, span("probe", 2_500, 10 * MINUTE_MS)].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.spanCount, 11); + assert.strictEqual(summary.minutes, 10); + assert.deepStrictEqual(summary.spans, [ + { + name: "refresh", + count: 10, + perMinute: 1, + p50Ms: 5, + p90Ms: 9, + maxMs: 10, + interrupted: 1, + failures: 1, + }, + { + name: "probe", + count: 1, + perMinute: 0.1, + p50Ms: 2_500, + p90Ms: 2_500, + maxMs: 2_500, + interrupted: 0, + failures: 0, + }, + ]); +}); + +it("drops spans that ended before the window and counts unreadable lines", () => { + const summarizer = makeTraceSpanSummary(MINUTE_MS); + [ + span("old", 1, 0), + "", + "{not json", + JSON.stringify({ name: "no-duration" }), + // Ends past the largest Date, so the report could not print it. + JSON.stringify({ name: "far-future", durationMs: 1, endTimeUnixNano: "9".repeat(22) }), + span("recent", 4, 5 * MINUTE_MS), + ].forEach(summarizer.addLine); + const summary = summarizer.finish(); + + assert.strictEqual(summary.skippedLineCount, 3); + assert.deepStrictEqual( + summary.spans.map((entry) => [entry.name, entry.count, entry.perMinute]), + [["recent", 1, undefined]], + ); +}); + +it("reads failures and interrupts of browser spans from their OTLP status", () => { + const summarizer = makeTraceSpanSummary(); + [ + browserSpan("render", { code: "2", message: "boom" }), + browserSpan("render", { code: "1", message: "Interrupted" }), + browserSpan("render", { code: "1" }), + ].forEach(summarizer.addLine); + + const [render] = summarizer.finish().spans; + assert.deepStrictEqual([render?.count, render?.interrupted, render?.failures], [3, 1, 1]); +}); diff --git a/apps/server/src/cli/trace.ts b/apps/server/src/cli/trace.ts new file mode 100644 index 000000000000..61a209471d6e --- /dev/null +++ b/apps/server/src/cli/trace.ts @@ -0,0 +1,208 @@ +/** + * `t3 trace summary` - per-span counts, rates, and latency percentiles from + * the local server trace file and its rotated backups. It reads the files + * directly, so it works while the server is stalled or stopped. + */ +import { PositiveInt } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Command, Flag } from "effect/unstable/cli"; + +import * as ServerConfig from "../config.ts"; +import { streamTraceFileLines, toRotatedTracePaths } from "../diagnostics/TraceDiagnostics.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag, DurationFromString, traceFileConfig, traceMaxFilesConfig } from "./config.ts"; + +// Only the fields the summary needs. Other record fields are ignored. +const decodeTraceSpanLine = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + name: Schema.String, + durationMs: Schema.Finite, + endTimeUnixNano: Schema.FiniteFromString, + // Server (`effect-span`) records. + exit: Schema.optional(Schema.Struct({ _tag: Schema.String })), + // Browser (`otlp-span`) records. Effect's OTLP tracer writes code "2" for + // errors and code "1" with message "Interrupted" for interrupts. + status: Schema.optional( + Schema.Struct({ + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +/** + * Groups trace NDJSON by span name. Call `addLine` once per line as the files + * stream in, then `finish` for the summary. Spans that ended before `sinceMs` + * are left out. Rates are per minute between the first and last span end, + * since spans are written when they end. + */ +export function makeTraceSpanSummary(sinceMs = -Infinity) { + // Keep each span's duration (8 bytes, a few MB for the default 110 MB of + // rotated traces) for exact percentiles. A bounded sketch would save little + // and make p50 and p90 approximate. + const byName = new Map(); + let spanCount = 0; + let skippedLineCount = 0; + let firstEndMs = Infinity; + let lastEndMs = -Infinity; + + const addLine = (line: string) => { + if (line.trim().length === 0) return; + const span = Option.getOrUndefined(decodeTraceSpanLine(line)); + if (span === undefined) { + skippedLineCount += 1; + return; + } + const endMs = span.endTimeUnixNano / 1_000_000; + // The report prints end times as dates, so skip ones outside the Date range. + if (Option.isNone(DateTime.make(endMs))) { + skippedLineCount += 1; + return; + } + if (endMs < sinceMs) return; + + spanCount += 1; + firstEndMs = Math.min(firstEndMs, endMs); + lastEndMs = Math.max(lastEndMs, endMs); + const stats = byName.get(span.name) ?? { durations: [], interrupted: 0, failures: 0 }; + stats.durations.push(span.durationMs); + if (span.exit?._tag === "Interrupted" || span.status?.message === "Interrupted") { + stats.interrupted += 1; + } + if (span.exit?._tag === "Failure" || span.status?.code === "2") stats.failures += 1; + byName.set(span.name, stats); + }; + + const finish = () => { + const minutes = (lastEndMs - firstEndMs) / 60_000; + const spans = [...byName] + .map(([name, { durations, interrupted, failures }]) => { + const sorted = durations.toSorted((left, right) => left - right); + // Nearest-rank percentile. + const percentile = (p: number) => sorted[Math.ceil(p * sorted.length) - 1]!; + return { + name, + count: sorted.length, + perMinute: minutes > 0 ? sorted.length / minutes : undefined, + p50Ms: percentile(0.5), + p90Ms: percentile(0.9), + maxMs: sorted[sorted.length - 1]!, + interrupted, + failures, + }; + }) + .toSorted((left, right) => right.count - left.count || left.name.localeCompare(right.name)); + + return { spanCount, skippedLineCount, firstEndMs, lastEndMs, minutes, spans }; + }; + + return { addLine, finish }; +} + +const formatMs = (ms: number) => + ms < 1_000 ? `${Math.round(ms)}ms` : `${(ms / 1_000).toFixed(1)}s`; + +function formatTraceSummary( + summary: ReturnType["finish"]>, + limit: number, +) { + const header = ["span", "count", "/min", "p50", "p90", "max", "interrupted", "failed"]; + const rows = summary.spans + .slice(0, limit) + .map((span) => [ + span.name, + String(span.count), + span.perMinute === undefined + ? "-" + : span.perMinute < 0.1 + ? "<0.1" + : span.perMinute.toFixed(1), + formatMs(span.p50Ms), + formatMs(span.p90Ms), + formatMs(span.maxMs), + String(span.interrupted), + String(span.failures), + ]); + const table = [header, ...rows]; + const widths = header.map((_, column) => Math.max(...table.map((row) => row[column]!.length))); + const formatIso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + return [ + `${summary.spanCount} spans ended from ${formatIso(summary.firstEndMs)} to ${formatIso(summary.lastEndMs)} (${summary.minutes.toFixed(1)} min).`, + ...(summary.skippedLineCount > 0 + ? [`Skipped ${summary.skippedLineCount} lines that are not spans.`] + : []), + "", + ...table.map((row) => + row + .map((cell, column) => + column === 0 ? cell.padEnd(widths[column]!) : cell.padStart(widths[column]!), + ) + .join(" "), + ), + ...(summary.spans.length > limit + ? ["", `${summary.spans.length - limit} more span names. Use --limit to show more.`] + : []), + ].join("\n"); +} + +const traceSummaryCommand = Command.make("summary", { + baseDir: baseDirFlag, + since: Flag.String("since").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("Only count spans that ended in this window, for example 30m or 2h."), + Flag.optional, + ), + limit: Flag.Int("limit").pipe( + Flag.withSchema(PositiveInt), + Flag.withDescription("Number of span names to show, busiest first."), + Flag.withDefault(25), + ), +}).pipe( + Command.withDescription("Summarize the local server trace file: counts, rates, and latency."), + Command.withHandler( + Effect.fn("cli.trace.summary")(function* (flags) { + const fs = yield* FileSystem.FileSystem; + // T3CODE_TRACE_FILE, else the userdata trace file for --base-dir or + // T3CODE_HOME. Implicit dev runs write elsewhere; set T3CODE_TRACE_FILE. + const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined(Option.orElse(flags.baseDir, () => envHome)), + ); + const traceFilePath = + (yield* traceFileConfig) ?? + (yield* ServerConfig.deriveServerPaths(baseDir, undefined)).serverTracePath; + const sinceMs = Option.isSome(flags.since) + ? (yield* Clock.currentTimeMillis) - Duration.toMillis(flags.since.value) + : undefined; + const summarizer = makeTraceSpanSummary(sinceMs); + yield* Effect.forEach( + toRotatedTracePaths(traceFilePath, yield* traceMaxFilesConfig), + (path) => streamTraceFileLines(fs, path, summarizer.addLine), + { discard: true }, + ); + const summary = summarizer.finish(); + + yield* Console.log( + summary.spanCount === 0 + ? `No spans found in ${traceFilePath} or its rotated files${sinceMs === undefined ? "" : " in that window"}.${summary.skippedLineCount > 0 ? ` Skipped ${summary.skippedLineCount} lines that are not spans.` : ""}` + : formatTraceSummary(summary, flags.limit), + ); + }), + ), +); + +export const traceCommand = Command.make("trace").pipe( + Command.withDescription("Inspect the local server trace file."), + Command.withSubcommands([traceSummaryCommand]), +); diff --git a/apps/server/src/cli/triage.ts b/apps/server/src/cli/triage.ts index c621d331c404..b408e3550112 100644 --- a/apps/server/src/cli/triage.ts +++ b/apps/server/src/cli/triage.ts @@ -27,6 +27,7 @@ import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; @@ -201,7 +202,11 @@ export const triageCommand = Command.make("triage", { dbPath: paths.dbPath, settingsPath: paths.settingsPath, logsDir: paths.logsDir, - serverLogPath: paths.serverLogPath, + // The server writes no log file of its own. Service installs and the + // desktop app capture its output. The glob covers every desktop backend + // (such as WSL) and rotated copies; names come from DesktopObservability.ts. + serviceLogPath: path.join(paths.logsDir, BootService.BOOT_SERVICE_LOG_FILE), + desktopBackendLogGlob: path.join(paths.logsDir, "server-child*.log*"), serverTracePath: paths.serverTracePath, providerEventLogPath: paths.providerEventLogPath, terminalLogsDir: paths.terminalLogsDir, diff --git a/apps/server/src/cli/triagePrompt.test.ts b/apps/server/src/cli/triagePrompt.test.ts index bf1ac5dbbe5e..fe65bf0dc444 100644 --- a/apps/server/src/cli/triagePrompt.test.ts +++ b/apps/server/src/cli/triagePrompt.test.ts @@ -52,7 +52,8 @@ it("context file carries every path the playbook depends on", () => { dbPath: "/home/u/.t3/userdata/state.sqlite", settingsPath: "/home/u/.t3/userdata/settings.json", logsDir: "/home/u/.t3/userdata/logs", - serverLogPath: "/home/u/.t3/userdata/logs/server.log", + 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", @@ -63,6 +64,8 @@ it("context file carries every path the playbook depends on", () => { }); assert.include(context, "/home/u/.t3/userdata/state.sqlite"); assert.include(context, "/home/u/.t3/userdata/logs/server.trace.ndjson"); + assert.include(context, "/home/u/.t3/userdata/logs/boot-service.log"); + assert.include(context, "/home/u/.t3/userdata/logs/server-child*.log*"); assert.include(context, "/home/u/.t3/userdata/logs/provider/events.log"); assert.include(context, "/home/u/.t3/userdata/secrets"); assert.include(context, "/home/u/.t3/source"); diff --git a/apps/server/src/cli/triagePrompt.ts b/apps/server/src/cli/triagePrompt.ts index c2b93a1840a1..1df712854263 100644 --- a/apps/server/src/cli/triagePrompt.ts +++ b/apps/server/src/cli/triagePrompt.ts @@ -71,8 +71,9 @@ different code depending on it: Then work from evidence, not assumption. In rough order of value: -- The server log and the trace file (\`server.trace.ndjson\`) around the time of the - problem. Recent failures usually leave a trail here. +- The trace file (\`server.trace.ndjson\`) around the time of the problem, plus the + service log or desktop backend logs from the context file if they exist. Recent + failures usually leave a trail here. - The provider event log, for problems with claude/codex/cursor sessions. - The SQLite database. Read it freely, but only write when a write is necessary to fix the problem the user described, and get their explicit permission @@ -176,7 +177,8 @@ export interface TriageContextInput { readonly dbPath: string; readonly settingsPath: string; readonly logsDir: string; - readonly serverLogPath: string; + readonly serviceLogPath: string; + readonly desktopBackendLogGlob: string; readonly serverTracePath: string; readonly providerEventLogPath: string; readonly terminalLogsDir: string; @@ -205,7 +207,8 @@ Generated by \`t3 triage\` at ${input.generatedAt}. - Database (SQLite; write only with the user's explicit permission): ${input.paths.dbPath} - Settings: ${input.paths.settingsPath} - Logs dir: ${input.paths.logsDir} -- Server log: ${input.paths.serverLogPath} +- Service log (systemd/launchd service installs only): ${input.paths.serviceLogPath} +- Desktop backend logs (glob; one file per backend plus rotated copies; written only when a backend crashes or fails to start): ${input.paths.desktopBackendLogGlob} - Server trace (ndjson): ${input.paths.serverTracePath} - Provider event log: ${input.paths.providerEventLogPath} - Terminal logs: ${input.paths.terminalLogsDir} diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts index 39f904b47b89..d59b89875e79 100644 --- a/apps/server/src/cloud/CliState.test.ts +++ b/apps/server/src/cloud/CliState.test.ts @@ -8,6 +8,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { ServerConfig } from "../config.ts"; import * as CliState from "./CliState.ts"; import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -24,6 +25,7 @@ const persistedCloudLinkSecrets = [ RELAY_ENVIRONMENT_CREDENTIAL_SECRET, CLOUD_MINT_PUBLIC_KEY, CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, PUBLISH_AGENT_ACTIVITY_SECRET, ] as const; diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index 9af9a032f856..dc77609ccc92 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -3,6 +3,7 @@ import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -67,6 +68,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), secrets.remove(CLOUD_MINT_PUBLIC_KEY), secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), ], { concurrency: "unbounded" }, diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 4172578d45f3..2a666651a9a5 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -341,7 +341,7 @@ const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams(params), httpClient.execute, - Effect.map(Option.some), + Effect.asSome, Effect.catchIf(isTransportError, () => Effect.succeedNone), ); // Transport failures and upstream 5xx are transient while the device code diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index ba2cf5c5ac05..71677a43d6b8 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -7,10 +7,12 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -38,7 +40,7 @@ const runtimeDependencies = ( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), relayClientLayer, Layer.mock(ServerSecretStore.ServerSecretStore)({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, }), ); @@ -62,6 +64,7 @@ function makeHandle(input: { readonly onKill: () => void; readonly isRunning?: () => boolean; readonly exitCode?: Effect.Effect; + readonly output?: Stream.Stream; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(input.pid), @@ -75,13 +78,70 @@ function makeHandle(input: { stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, - all: Stream.empty, + all: input.output ?? Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); } describe("CloudManagedEndpointRuntime", () => { + it("retries connector startup failures but stops for unsupported runtimes", () => { + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "not-installed", + reason: "The relay client is not installed.", + }), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "spawn-failed", + reason: "spawn failed", + }), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ + status: "failed", + failure: "unsupported-platform", + reason: "Relay client is unsupported on linux-arm.", + }), + ).toBe(false); + expect( + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus({ status: "unsupported" }), + ).toBe(false); + }); + + it.effect("serializes updates to persisted cloud link state", () => + Effect.gen(function* () { + const firstEntered = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const secondEntered = yield* Deferred.make(); + const runtime = yield* buildCloudManagedEndpointRuntime( + ChildProcessSpawner.make(() => Effect.die("unused")), + ); + + const first = yield* runtime + .withLinkStateLock( + Deferred.succeed(firstEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirst)), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(firstEntered); + + const second = yield* runtime + .withLinkStateLock(Deferred.succeed(secondEntered, undefined)) + .pipe(Effect.forkChild); + expect(yield* Deferred.isDone(secondEntered)).toBe(false); + + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + expect(yield* Deferred.isDone(secondEntered)).toBe(true); + }), + ); + it("classifies Cloudflare connection and warning output", () => { expect( ManagedEndpointRuntime.classifyRelayClientOutput( @@ -109,6 +169,125 @@ describe("CloudManagedEndpointRuntime", () => { ).toBe("warning"); }); + it("recognizes tunnel authorization failures without matching ordinary transport errors", () => { + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-09-15T06:30:43Z ERR Register tunnel error from server side error="Failed to get tunnel" connIndex=0 event=0 ip=198.41.200.23', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Record for tunnel not found" connIndex=0', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="Unauthorized: Invalid tunnel secret" connIndex=0', + ), + ).toBe(true); + expect( + ManagedEndpointRuntime.isRejectedRelayClientTunnelOutput( + '2026-06-17T02:00:00Z ERR Register tunnel error from server side error="connection timed out" connIndex=0', + ), + ).toBe(false); + }); + + it.effect("keeps recovery requests sent before the server starts consuming them", () => + Effect.gen(function* () { + const runtime = yield* buildCloudManagedEndpointRuntime( + ChildProcessSpawner.make(() => Effect.die("unused")), + ); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "tunnel-1", + }; + + yield* runtime.requestRecovery(config); + + expect(Option.getOrNull(yield* Stream.runHead(runtime.recoveryRequests))).toEqual(config); + }), + ); + + it.effect("recovers a rejected tunnel without waiting for the connector to exit", () => + Effect.gen(function* () { + const output = yield* Queue.unbounded(); + const firstBatchObserved = yield* Deferred.make(); + const secondBatchObserved = yield* Deferred.make(); + const recoveryRequested = yield* Deferred.make(); + const recoveryRetried = yield* Deferred.make(); + let recoveryRequestCount = 0; + const spawned: Array = []; + const encoder = new TextEncoder(); + const connectorOutput = Stream.fromQueue(output).pipe( + Stream.tap((chunk) => { + const line = new TextDecoder().decode(chunk); + if (line === "first checkpoint\n") { + return Deferred.succeed(firstBatchObserved, undefined).pipe(Effect.asVoid); + } + if (line === "second checkpoint\n") { + return Deferred.succeed(secondBatchObserved, undefined).pipe(Effect.asVoid); + } + return Effect.void; + }), + ); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = 600; + spawned.push(pid); + const handle = makeHandle({ pid, onKill: () => {}, output: connectorOutput }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "deleted-tunnel", + }; + const rejectedLine = + '2026-09-15T06:30:43Z ERR Register tunnel error from server side error="Failed to get tunnel" connIndex=0 event=0 ip=198.41.200.23\n'; + + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((requested) => { + recoveryRequestCount += 1; + return Deferred.succeed( + recoveryRequestCount === 1 ? recoveryRequested : recoveryRetried, + requested, + ).pipe(Effect.asVoid); + }), + Effect.forkChild, + ); + yield* runtime.applyConfig(config); + + yield* Queue.offer(output, encoder.encode(rejectedLine.repeat(3))); + yield* Queue.offer(output, encoder.encode("first checkpoint\n")); + yield* Deferred.await(firstBatchObserved); + expect(yield* Deferred.isDone(recoveryRequested)).toBe(false); + + yield* Queue.offer( + output, + encoder.encode( + "2026-06-17T02:00:00Z INF Registered tunnel connection connIndex=0\n" + + rejectedLine.repeat(3), + ), + ); + yield* Queue.offer(output, encoder.encode("second checkpoint\n")); + yield* Deferred.await(secondBatchObserved); + expect(yield* Deferred.isDone(recoveryRequested)).toBe(false); + + yield* Queue.offer(output, encoder.encode(rejectedLine)); + + expect(yield* Deferred.await(recoveryRequested)).toEqual(config); + + yield* Queue.offer(output, encoder.encode(rejectedLine.repeat(4))); + + expect(yield* Deferred.await(recoveryRetried)).toEqual(config); + expect(spawned).toEqual([600]); + }), + ); + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => Effect.gen(function* () { const spawned: Array = []; @@ -156,8 +335,8 @@ describe("CloudManagedEndpointRuntime", () => { expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); expect(spawned.map((command) => command.args)).toEqual([ - ["tunnel", "run"], - ["tunnel", "run"], + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], ]); expect(spawned.map((command) => command.options.env?.TUNNEL_TOKEN)).toEqual([ "token-1", @@ -378,6 +557,37 @@ describe("CloudManagedEndpointRuntime", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("a recovery that returns the same config keeps the crash backoff", () => + Effect.gen(function* () { + const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(900, 4); + const runtime = yield* buildCloudManagedEndpointRuntime(spawner); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "same-token", + tunnelId: "same-tunnel", + }; + // The startup consumer re-applies whatever the relay hands back. When the + // relay confirms the current tunnel, that must not look like a config change. + yield* runtime.recoveryRequests.pipe( + Stream.runForEach((requested) => runtime.applyConfig(requested).pipe(Effect.asVoid)), + Effect.forkChild, + ); + + yield* runtime.applyConfig(config); + yield* Deferred.succeed(exits[0]!, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(spawnSignals[1]!); + expect(spawned).toEqual([900, 901]); + + // Second rapid crash still waits out the base delay. + yield* Deferred.succeed(exits[1]!, ChildProcessSpawner.ExitCode(1)); + yield* TestClock.adjust(Duration.millis(999)); + expect(spawned).toEqual([900, 901]); + yield* TestClock.adjust(Duration.millis(1)); + yield* Deferred.await(spawnSignals[2]!); + expect(spawned).toEqual([900, 901, 902]); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("an explicit config change clears the backoff and preempts a delayed restart", () => Effect.gen(function* () { const { spawner, spawned, exits, spawnSignals } = yield* makeCrashLoopSpawner(800, 3); @@ -510,6 +720,7 @@ describe("CloudManagedEndpointRuntime", () => { expect(status).toEqual({ status: "failed", providerKind: "cloudflare_tunnel", + failure: "not-installed", reason: "The relay client is not installed.", }); expect(spawn).not.toHaveBeenCalled(); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index cc657bdebf1b..21091c5c446e 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -6,7 +6,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Semaphore from "effect/Semaphore"; @@ -15,22 +15,6 @@ import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; - -function bytesToString(bytes: Uint8Array): string { - return new TextDecoder().decode(bytes); -} - -const readRuntimeConfig = Effect.gen(function* () { - const secrets = yield* ServerSecretStore.ServerSecretStore; - const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); - if (Option.isNone(bytes)) { - return null; - } - return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes.value))); -}); - export type CloudManagedEndpointRuntimeStatus = | { readonly status: "disabled"; @@ -38,6 +22,7 @@ export type CloudManagedEndpointRuntimeStatus = | { readonly status: "failed"; readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; + readonly failure: "unsupported-platform" | "not-installed" | "spawn-failed"; readonly reason: string; readonly tunnelId?: string; readonly tunnelName?: string; @@ -60,6 +45,9 @@ export class CloudManagedEndpointRuntime extends Context.Service< readonly applyConfig: ( config: RelayManagedEndpointRuntimeConfig | null, ) => Effect.Effect; + readonly recoveryRequests: Stream.Stream; + readonly requestRecovery: (config: RelayManagedEndpointRuntimeConfig) => Effect.Effect; + readonly withLinkStateLock: (effect: Effect.Effect) => Effect.Effect; } >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} @@ -79,6 +67,8 @@ interface ActiveConnector { const RELAY_RESTART_STABLE_UPTIME_MS = 30_000; const RELAY_RESTART_BACKOFF_BASE_MS = 1_000; const RELAY_RESTART_BACKOFF_MAX_MS = 60_000; +// Newly created tunnels can fail authorization briefly while Cloudflare propagates their token. +const TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY = 4; export function classifyRelayClientOutput(line: string): "connected" | "warning" | "debug" { if (/\bRegistered tunnel connection\b/iu.test(line)) { @@ -90,6 +80,32 @@ export function classifyRelayClientOutput(line: string): "connected" | "warning" return /\b(?:ERR|WRN|FTL|PNC)\b/u.test(line) ? "warning" : "debug"; } +/** + * Cloudflare's edge rejects a connector whose tunnel was deleted or whose + * token no longer matches. Current edge output is + * `error="Failed to get tunnel"` with no prefix; older edges prefixed the + * same messages with `Unauthorized:`. Match both so recovery fires on either. + */ +export function isRejectedRelayClientTunnelOutput(line: string): boolean { + return ( + /\bRegister tunnel error from server side\b/iu.test(line) && + /error="(?:Unauthorized:\s*)?(?:Failed to get tunnel|Record for tunnel not found|Invalid tunnel secret)"/iu.test( + line, + ) + ); +} + +/** Connector startup failures can clear after installation or a later spawn attempt. */ +export function isRetryableManagedEndpointRuntimeStatus(status: unknown): boolean { + if (typeof status !== "object" || status === null || !("status" in status)) { + return false; + } + if (status.status !== "failed" || !("failure" in status)) { + return false; + } + return status.failure === "not-installed" || status.failure === "spawn-failed"; +} + function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { return JSON.stringify({ providerKind: config.providerKind, @@ -117,8 +133,10 @@ export const make = Effect.gen(function* () { const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); + const recoveryRequests = yield* Queue.sliding(1); const reconcileSemaphore = yield* Semaphore.make(1); const restartDelayRef = yield* Ref.make(0); + const linkStateSemaphore = yield* Semaphore.make(1); let reconcileConfig: CloudManagedEndpointRuntime["Service"]["applyConfig"]; const stopActive = Effect.gen(function* () { @@ -191,6 +209,7 @@ export const make = Effect.gen(function* () { tunnelId: connector.config.tunnelId, tunnelName: connector.config.tunnelName, }); + yield* Queue.offer(recoveryRequests, connector.config); yield* reconcileConfig(desiredConfig); }), ); @@ -198,8 +217,10 @@ export const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); - const observeConnectorOutput = (connector: ActiveConnector) => - connector.child.all.pipe( + const observeConnectorOutput = (connector: ActiveConnector) => { + let rejectedRegistrations = 0; + + return connector.child.all.pipe( Stream.decodeText(), Stream.splitLines, Stream.map((line) => line.trim()), @@ -214,8 +235,22 @@ export const make = Effect.gen(function* () { }; switch (classifyRelayClientOutput(line)) { case "connected": + rejectedRegistrations = 0; return Effect.logInfo("Relay client tunnel connection registered", attributes); case "warning": + if (isRejectedRelayClientTunnelOutput(line)) { + rejectedRegistrations += 1; + if (rejectedRegistrations >= TUNNEL_AUTHORIZATION_FAILURES_BEFORE_RECOVERY) { + rejectedRegistrations = 0; + return Effect.logWarning( + "Relay client tunnel was rejected; requesting recovery", + attributes, + ).pipe( + Effect.andThen(Queue.offer(recoveryRequests, connector.config)), + Effect.asVoid, + ); + } + } return Effect.logWarning("Relay client reported a transport warning", attributes); case "debug": return Effect.logDebug("Relay client output", attributes); @@ -230,6 +265,7 @@ export const make = Effect.gen(function* () { }), ), ); + }; reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { if (!config || config.providerKind !== "cloudflare_tunnel") { @@ -261,6 +297,7 @@ export const make = Effect.gen(function* () { return { status: "failed", providerKind: "cloudflare_tunnel", + failure: executable.status === "unsupported" ? "unsupported-platform" : "not-installed", reason: executable.status === "unsupported" ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.` @@ -273,16 +310,20 @@ export const make = Effect.gen(function* () { const connectorScope = yield* Scope.make("sequential"); const child = yield* spawner .spawn( - ChildProcess.make(executable.executablePath, ["tunnel", "run"], { - detached: false, - env: { - ...process.env, - TUNNEL_TOKEN: config.connectorToken, + ChildProcess.make( + executable.executablePath, + ["tunnel", "--no-autoupdate", "--loglevel", "info", "--output", "default", "run"], + { + detached: false, + env: { + ...process.env, + TUNNEL_TOKEN: config.connectorToken, + }, + shell: false, + stderr: "pipe", + stdout: "pipe", }, - shell: false, - stderr: "pipe", - stdout: "pipe", - }), + ), ) .pipe( Effect.provideService(Scope.Scope, connectorScope), @@ -303,6 +344,7 @@ export const make = Effect.gen(function* () { Effect.as({ status: "failed", providerKind: "cloudflare_tunnel", + failure: "spawn-failed", reason: String(cause), ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), @@ -338,6 +380,7 @@ export const make = Effect.gen(function* () { return { status: "failed", providerKind: "cloudflare_tunnel", + failure: "spawn-failed", reason: "Relay client did not start.", ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), @@ -347,26 +390,31 @@ export const make = Effect.gen(function* () { const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( (config: RelayManagedEndpointRuntimeConfig | null) => reconcileSemaphore.withPermits(1)( - // An explicit config change starts over with a fresh backoff. - Ref.set(restartDelayRef, 0).pipe( - Effect.andThen(Ref.set(desiredConfigRef, config)), - Effect.andThen(reconcileConfig(config)), - ), + Effect.gen(function* () { + // A real config change starts over with a fresh backoff. Recovery + // that hands back the same tunnel and token must keep the delay, or + // a crash-looping connector respawns on every recovery round trip. + const desired = yield* Ref.get(desiredConfigRef); + const unchanged = + desired !== null && + config !== null && + runtimeConfigKey(desired) === runtimeConfigKey(config); + if (!unchanged) { + yield* Ref.set(restartDelayRef, 0); + } + yield* Ref.set(desiredConfigRef, config); + return yield* reconcileConfig(config); + }), ), ); const runtime = CloudManagedEndpointRuntime.of({ applyConfig, + recoveryRequests: Stream.fromQueue(recoveryRequests), + requestRecovery: (config) => Queue.offer(recoveryRequests, config).pipe(Effect.asVoid), + withLinkStateLock: linkStateSemaphore.withPermits(1), }); - const initialConfig = yield* readRuntimeConfig.pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( - Effect.as(null), - ), - ), - ); - yield* runtime.applyConfig(initialConfig); yield* Effect.addFinalizer(() => runtime.applyConfig(null)); return runtime; }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 633778641371..4db311c646b1 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -43,6 +43,8 @@ const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; +/** File in the logs dir that receives the service's stdout and stderr. `t3 triage` points agents at it. */ +export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ function escapeSystemdSpecifiers(value: string): string { @@ -599,7 +601,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { environmentPath, }); const unitPath = detectedManager?.unitPath ?? ""; - const logPath = path.join(input.logsDir, "boot-service.log"); + const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 2eff693f61e6..9b1b281ba2da 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -1,4 +1,7 @@ -import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import { + RelayManagedEndpointOrigin, + RelayManagedEndpointRuntimeConfig, +} from "@t3tools/contracts/relay"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -7,6 +10,7 @@ import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; +export const CLOUD_ENDPOINT_CONFIRMED_ORIGIN = "cloud-endpoint-confirmed-origin"; export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; export const RELAY_URL_SECRET = "cloud-relay-url"; export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; @@ -21,6 +25,19 @@ export const decodeRuntimeConfig = Schema.decodeUnknownOption( Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), ); +export const ManagedEndpointConfirmedOrigin = Schema.Struct({ + config: RelayManagedEndpointRuntimeConfig, + origin: RelayManagedEndpointOrigin, +}); + +export const encodeConfirmedOriginJson = Schema.encodeEffect( + Schema.fromJsonString(ManagedEndpointConfirmedOrigin), +); + +export const decodeConfirmedOrigin = Schema.decodeUnknownOption( + Schema.fromJsonString(ManagedEndpointConfirmedOrigin), +); + export function isAgentActivityPublishingEnabledValue(value: string | null): boolean { return value === "true"; } diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 0f24e6f34176..1d6b78e56cef 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -1,12 +1,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; +import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse, @@ -14,7 +20,7 @@ import { type HttpClientRequest, } from "effect/unstable/http"; -import { EnvironmentId } from "@t3tools/contracts"; +import { DESKTOP_UPDATE_RESTART_MARKER_FILE, EnvironmentId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; @@ -27,18 +33,40 @@ import { type ServiceUpdateRecord, } from "./serviceProtocol.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import type { RelayLinkProofRequest } from "@t3tools/contracts/relay"; -import { CLOUD_ENDPOINT_RUNTIME_CONFIG, RELAY_URL_SECRET } from "./config.ts"; +import { + RelayManagedEndpointRecoveryRegistrationRequest, + type RelayLinkProofRequest, +} from "@t3tools/contracts/relay"; +import { + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + decodeConfirmedOrigin, + decodeRuntimeConfig, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; import { consumeCloudReplayGuards, isSupportedLinkProviderKind, linkProofScopes, pendingServiceUpdateExists, + parseManagedEndpointLocalOrigin, reconcileDesiredCloudLink, + reconcileDesiredCloudLinkIfStillDesired, + recoverManagedCloudTunnel, + registerManagedCloudTunnelRecovery, releaseManagedTunnelOnShutdown, + startManagedCloudTunnelIfOriginConfirmed, } from "./http.ts"; +import { + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./managedTunnelStartup.ts"; +import { shouldRetryCloudLink } from "./relayResponse.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; @@ -54,6 +82,15 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); +// Linking wakes the awareness relay; these tests do not run it. +const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ + publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, + start: () => Effect.void, +}); +const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect( + Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest), +); function makeSecretStore( create: ServerSecretStore.ServerSecretStore["Service"]["create"], @@ -209,6 +246,9 @@ describe("reconcileDesiredCloudLink", () => { ManagedEndpointRuntime.CloudManagedEndpointRuntime, ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ applyConfig: unusedSecretStoreOperation, + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), ), Effect.provideService( @@ -219,7 +259,7 @@ describe("reconcileDesiredCloudLink", () => { CliTokenManager.CloudCliTokenManager, CliTokenManager.CloudCliTokenManager.of({ get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.none()), + getExisting: Effect.succeedNone, hasCredential: unusedSecretStoreOperation(), store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), @@ -229,11 +269,45 @@ describe("reconcileDesiredCloudLink", () => { HttpClient.HttpClient, HttpClient.make(() => unusedSecretStoreOperation()), ), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provide(NodeServices.layer), ), ); }); +describe("parseManagedEndpointLocalOrigin", () => { + it.each([ + { + input: "http://127.0.0.1:80", + httpBaseUrl: "http://127.0.0.1", + wsBaseUrl: "ws://127.0.0.1", + port: 80, + }, + { + input: "https://127.0.0.1:443", + httpBaseUrl: "https://127.0.0.1", + wsBaseUrl: "wss://127.0.0.1", + port: 443, + }, + ])("accepts an explicit default port in $input", ({ input, httpBaseUrl, wsBaseUrl, port }) => { + expect(parseManagedEndpointLocalOrigin(input)).toEqual({ + httpBaseUrl, + wsBaseUrl, + origin: { localHttpHost: "127.0.0.1", localHttpPort: port }, + }); + }); + + it.each([ + "ftp://127.0.0.1:3773", + "http://user:password@127.0.0.1:3773", + "http://127.0.0.1:3773/api", + "http://127.0.0.1:3773?mode=test", + "http://127.0.0.1:3773#fragment", + ])("rejects non-origin URL %s", (input) => { + expect(() => parseManagedEndpointLocalOrigin(input)).toThrow("Invalid local origin"); + }); +}); + describe("releaseManagedTunnelOnShutdown", () => { const cliToken: CliTokenManager.PersistedToken = { accessToken: "cli-access-token", @@ -251,7 +325,10 @@ describe("releaseManagedTunnelOnShutdown", () => { Effect.sync(() => { values.set(name, value); }), - create: unusedSecretStoreOperation, + create: (name, value) => + Effect.sync(() => { + values.set(name, value); + }), getOrCreateRandom: unusedSecretStoreOperation, remove: (name) => Effect.sync(() => { @@ -265,7 +342,9 @@ describe("releaseManagedTunnelOnShutdown", () => { readonly store: ServerSecretStore.ServerSecretStore["Service"]; readonly applyConfigCalls: Array; readonly requests: Array; + readonly onRequest?: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect; readonly respond?: () => Response; + readonly respondEffect?: Effect.Effect; } // Writes the launcher's durable state file into this test's baseDir with @@ -285,11 +364,26 @@ describe("releaseManagedTunnelOnShutdown", () => { ); }); + // Writes the marker the desktop app leaves just before it stops its backend + // to install an update, and returns when it was written. + const writeDesktopUpdateRestartMarker = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfigModule.ServerConfig; + const runtimeDir = path.join(config.baseDir, "runtime"); + const markerPath = path.join(runtimeDir, DESKTOP_UPDATE_RESTART_MARKER_FILE); + yield* fs.makeDirectory(runtimeDir, { recursive: true }); + yield* fs.writeFileString(markerPath, ""); + const { mtime } = yield* fs.stat(markerPath); + return Option.getOrThrow(mtime).getTime(); + }); + const provideReleaseHarness = (harness: ReleaseHarness) => (effect: Effect.Effect) => effect.pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ @@ -303,10 +397,19 @@ describe("releaseManagedTunnelOnShutdown", () => { applyConfig: (config) => Effect.sync(() => { harness.applyConfigCalls.push(config); - return { - status: "disabled", - } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus; + return config === null + ? ({ + status: "disabled", + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus) + : ({ + status: "running", + providerKind: "cloudflare_tunnel", + pid: 123, + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntimeStatus); }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, }), ), Effect.provideService( @@ -317,7 +420,7 @@ describe("releaseManagedTunnelOnShutdown", () => { CliTokenManager.CloudCliTokenManager, CliTokenManager.CloudCliTokenManager.of({ get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.some(cliToken)), + getExisting: Effect.succeedSome(cliToken), hasCredential: unusedSecretStoreOperation(), store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), @@ -328,11 +431,14 @@ describe("releaseManagedTunnelOnShutdown", () => { HttpClient.make((request) => Effect.sync(() => { harness.requests.push(request); - return HttpClientResponse.fromWeb( - request, - (harness.respond ?? (() => Response.json({ ok: true })))(), - ); - }), + }).pipe( + Effect.andThen(harness.onRequest?.(request) ?? Effect.void), + Effect.andThen( + harness.respondEffect ?? + Effect.sync(() => (harness.respond ?? (() => Response.json({ ok: true })))()), + ), + Effect.map((response) => HttpClientResponse.fromWeb(request, response)), + ), ), ), // The release consults the launcher state file under the configured @@ -348,10 +454,27 @@ describe("releaseManagedTunnelOnShutdown", () => { // The persisted state of a CLI-managed link whose tunnel is releasable. const managedLinkSecrets = [ [CLOUD_ENDPOINT_RUNTIME_CONFIG, "runtime-config"], + [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"], [RELAY_URL_SECRET, "https://relay.example.test"], [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], ] as const; + it.effect("does not recreate a link that was unlinked while startup registration retried", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + // Registration started while this marker existed. Unlink removes it + // before startup receives the relay's final not_linked response. + values.delete(CLOUD_CLI_DESIRED_LINK_SECRET); + + expect(yield* reconcileDesiredCloudLinkIfStillDesired("http://127.0.0.1:3773")).toBeNull(); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("stops the connector, releases the relay tunnel, and drops the dead token", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; @@ -370,6 +493,7 @@ describe("releaseManagedTunnelOnShutdown", () => { ); expect(request.headers.authorization).toBe("Bearer cli-access-token"); expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); + expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false); }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); @@ -454,6 +578,38 @@ describe("releaseManagedTunnelOnShutdown", () => { }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); }); + it.effect("keeps the tunnel once when the desktop app restarts it for an update", () => { + const { store, values } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + yield* TestClock.setTime(yield* writeDesktopUpdateRestartMarker); + + expect(yield* releaseManagedTunnelOnShutdown()).toBe(false); + expect(requests).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(true); + + // The shutdown consumed the marker, so a later quit releases the tunnel. + expect(yield* releaseManagedTunnelOnShutdown()).toBe(true); + expect(requests).toHaveLength(1); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("releases the tunnel when the desktop update marker is stale", () => { + const { store } = makeMemorySecretStore(managedLinkSecrets); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const writtenAt = yield* writeDesktopUpdateRestartMarker; + yield* TestClock.setTime(writtenAt + Duration.toMillis(Duration.minutes(2))); + + expect(yield* releaseManagedTunnelOnShutdown()).toBe(true); + expect(requests).toHaveLength(1); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("still releases a pending update when the launcher is stopping", () => { // `t3 service uninstall` or `systemctl stop` during the pending window: // the launcher writes its stop marker before signalling the child, so no @@ -579,6 +735,501 @@ describe("releaseManagedTunnelOnShutdown", () => { }), ); }); + + it.effect("registers an existing tunnel and starts the confirmed connector", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773")).toMatchObject({ + status: "ready", + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe( + "https://relay.example.test/v1/environments/env_123/tunnel/recovery", + ); + expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential"); + const body = requests[0]?.body; + expect(body?._tag).toBe("Uint8Array"); + if (body?._tag === "Uint8Array") { + expect( + yield* decodeManagedTunnelRecoveryRegistration(new TextDecoder().decode(body.body)), + ).toMatchObject({ + cloudUserId: "user-123", + tunnelId: "existing-tunnel", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + } + expect(applyConfigCalls).toHaveLength(1); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({ status: "ready" }), + }), + ); + }); + + it.effect("reconciles a changed port after a relay outage outlasts the startup fallback", () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)], + [ + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + JSON.stringify({ + config, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + let relayAvailable = false; + const localOrigin = "http://127.0.0.1:4884"; + + return Effect.gen(function* () { + const fallbackStarted = yield* Deferred.make(); + const firstFailure = yield* Deferred.make(); + expect(yield* startManagedCloudTunnelIfOriginConfirmed(localOrigin)).toBe(false); + const registration = yield* Effect.forkChild( + retryManagedTunnelRegistration( + registerManagedCloudTunnelRecovery(localOrigin).pipe( + Effect.tapError(() => Deferred.succeed(firstFailure, undefined)), + ), + shouldRetryCloudLink, + startManagedCloudTunnelIfOriginConfirmed(localOrigin, { + requireConfirmedOrigin: false, + }).pipe( + Effect.orDie, + Effect.tap((started) => { + expect(started).toBe(true); + return Deferred.succeed(fallbackStarted, undefined); + }), + Effect.asVoid, + ), + ), + { startImmediately: true }, + ); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("15 minutes"); + yield* Effect.raceFirst( + Deferred.await(fallbackStarted), + Fiber.join(registration).pipe( + Effect.andThen(Effect.die("Registration ended before starting the fallback")), + ), + ); + expect(applyConfigCalls).toEqual([config]); + const attemptsBeforeRecovery = requests.length; + + relayAvailable = true; + yield* TestClock.adjust("1 minute"); + expect(yield* Fiber.join(registration)).toMatchObject({ status: "ready" }); + expect(requests.length).toBeGreaterThan(attemptsBeforeRecovery); + const marker = yield* store.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + expect(Option.isSome(marker)).toBe(true); + if (Option.isSome(marker)) { + expect( + Option.getOrThrow(decodeConfirmedOrigin(new TextDecoder().decode(marker.value))), + ).toEqual({ + config, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 4884 }, + }); + } + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => + relayAvailable + ? Response.json({ status: "ready" }) + : Response.json({ message: "relay unavailable" }, { status: 503 }), + }), + ); + }); + + it.effect( + "starts a connector with a marker for the current origin without contacting relay", + () => { + const configJson = + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}'; + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, configJson], + [ + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + `{"config":${configJson},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}`, + ], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* startManagedCloudTunnelIfOriginConfirmed("http://127.0.0.1:3773")).toBe(true); + expect(applyConfigCalls).toEqual([config]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }, + ); + + it.effect.each([ + { name: "missing", marker: undefined, origin: "http://127.0.0.1:3773" }, + { + name: "stale", + marker: + '{"config":{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"},"origin":{"localHttpHost":"127.0.0.1","localHttpPort":3773}}', + origin: "http://127.0.0.1:4884", + }, + ])("does not start a connector with a $name origin marker", ({ marker, origin }) => { + const entries: Array = [ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}', + ], + ]; + if (marker !== undefined) entries.push([CLOUD_ENDPOINT_CONFIRMED_ORIGIN, marker]); + const { store } = makeMemorySecretStore(entries); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* startManagedCloudTunnelIfOriginConfirmed(origin)).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect( + "starts the stored connector without a marker when confirmation is not required", + () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "existing-token", + tunnelId: "existing-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, JSON.stringify(config)], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect( + yield* startManagedCloudTunnelIfOriginConfirmed("http://127.0.0.1:3773", { + requireConfirmedOrigin: false, + }), + ).toBe(true); + expect(applyConfigCalls).toEqual([config]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }, + ); + + it.effect.each(["replaced", "removed"] as const)( + "does not activate a tunnel when its runtime config is %s during registration", + (mutation) => { + const originalConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"existing-token","tunnelId":"existing-tunnel"}'; + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, originalConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773")).toEqual({ + status: "superseded", + }); + expect(applyConfigCalls).toEqual([]); + expect(values.has(CLOUD_ENDPOINT_CONFIRMED_ORIGIN)).toBe(false); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => { + if (mutation === "replaced") { + values.set( + CLOUD_ENDPOINT_RUNTIME_CONFIG, + new TextEncoder().encode( + '{"providerKind":"cloudflare_tunnel","connectorToken":"fresh-token","tunnelId":"fresh-tunnel"}', + ), + ); + } else { + values.delete(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + return Response.json({ status: "ready" }); + }, + }), + ); + }, + ); + + it.effect("requests startup recovery for a legacy config without a recorded tunnel ID", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"token"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const registration = yield* registerManagedCloudTunnelRecovery("http://127.0.0.1:3773"); + expect(registration).toEqual({ + status: "recovery_required", + config: { providerKind: "cloudflare_tunnel", connectorToken: "token" }, + }); + expect( + managedTunnelStartupAction({ + wantsCliLink: false, + registration, + }), + ).toEqual({ + action: "request_recovery", + config: { providerKind: "cloudflare_tunnel", connectorToken: "token" }, + }); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("recovers a web-linked tunnel with its environment credential", () => { + const oldConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; + const nextConfig = { + providerKind: "cloudflare_tunnel", + connectorToken: "new-token", + tunnelId: "new-tunnel", + } as const; + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe("https://relay.example.test/v1/environments/env_123/tunnel"); + expect(requests[0]?.headers.authorization).toBe("Bearer environment-credential"); + expect(applyConfigCalls).toEqual([nextConfig]); + expect( + Option.getOrNull( + decodeRuntimeConfig(new TextDecoder().decode(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG))), + ), + ).toEqual(nextConfig); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => + Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: nextConfig, + }), + }), + ); + }); + + it.effect("allows managed tunnel provisioning to take longer than ten seconds", () => + Effect.gen(function* () { + const oldConfig = + '{"providerKind":"cloudflare_tunnel","connectorToken":"old-token","tunnelId":"old-tunnel"}'; + const nextConfig = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "new-token", + tunnelId: "new-tunnel", + }; + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, oldConfig], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + const requestStarted = yield* Deferred.make(); + const response = yield* Deferred.make(); + const recovery = yield* recoverManagedCloudTunnel("http://127.0.0.1:3773").pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + onRequest: () => Deferred.succeed(requestStarted, undefined), + respondEffect: Deferred.await(response), + }), + Effect.forkChild({ startImmediately: true }), + ); + + yield* Deferred.await(requestStarted); + expect(requests).toHaveLength(1); + yield* TestClock.adjust("11 seconds"); + yield* Effect.yieldNow; + yield* Deferred.succeed( + response, + Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: nextConfig, + }), + ); + + expect(yield* Fiber.join(recovery)).toBe(true); + expect(requests).toHaveLength(1); + expect(applyConfigCalls).toEqual([nextConfig]); + }), + ); + + it.effect("does not recover an environment without a managed tunnel credential", () => { + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(false); + expect(applyConfigCalls).toEqual([]); + expect(requests).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect("ignores recovery requests for a tunnel that has already been replaced", () => { + const { store } = makeMemorySecretStore([ + [ + CLOUD_ENDPOINT_RUNTIME_CONFIG, + '{"providerKind":"cloudflare_tunnel","connectorToken":"current-token","tunnelId":"current-tunnel"}', + ], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + expect( + yield* recoverManagedCloudTunnel("http://127.0.0.1:3773", { + providerKind: "cloudflare_tunnel", + connectorToken: "old-token", + tunnelId: "old-tunnel", + }), + ).toBe(false); + expect(requests).toEqual([]); + expect(applyConfigCalls).toEqual([]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + + it.effect.each([ + { status: 401, errorTag: "EnvironmentHttpUnauthorizedError" }, + { status: 403, errorTag: "EnvironmentHttpForbiddenError" }, + { status: 409, errorTag: "EnvironmentHttpBadRequestError" }, + ])("preserves a permanent $status relay recovery failure", ({ status, errorTag }) => { + const { store } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + + return Effect.gen(function* () { + const error = yield* Effect.flip(recoverManagedCloudTunnel("http://127.0.0.1:3773")); + + expect(error._tag).toBe(errorTag); + expect(requests).toHaveLength(1); + expect(applyConfigCalls).toEqual([]); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => Response.json({}, { status }), + }), + ); + }); + + it.effect("keeps a tunnel configuration replaced during recovery", () => { + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "old-config"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "environment-credential"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + const freshConfig = new TextEncoder().encode("fresh-config"); + + return Effect.gen(function* () { + expect(yield* recoverManagedCloudTunnel("http://127.0.0.1:3773")).toBe(false); + expect(values.get(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(freshConfig); + expect(applyConfigCalls).toEqual([]); + }).pipe( + provideReleaseHarness({ + store, + applyConfigCalls, + requests, + respond: () => { + values.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, freshConfig); + return Response.json({ + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "replacement-token", + }, + }); + }, + }), + ); + }); }); describe("link proof provider kinds", () => { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index e0d458b4b97c..a7e3de8c7283 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -11,6 +11,7 @@ import { EnvironmentHttpConflictError, EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, + DESKTOP_UPDATE_RESTART_MARKER_FILE, } from "@t3tools/contracts"; import { RelayCloudEnvironmentHealthProofPayload, @@ -28,6 +29,10 @@ import { RelayEnvironmentLinkProofPayload, RelayLinkProofRequest, RelayManagedEndpointOrigin, + RelayManagedEndpointRecoveryProofPayload, + RelayManagedEndpointRecoveryRegistrationResponse, + RelayManagedEndpointRecoveryResponse, + type RelayManagedEndpointRuntimeConfig, RelayOkResponse, } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; @@ -36,12 +41,14 @@ import { RELAY_HEALTH_REQUEST_TYP, RELAY_HEALTH_RESPONSE_TYP, RELAY_LINK_PROOF_TYP, + RELAY_MANAGED_TUNNEL_RECOVERY_TYP, RELAY_MINT_REQUEST_TYP, RELAY_MINT_RESPONSE_TYP, signRelayJwt, verifyRelayJwt, } from "@t3tools/shared/relayJwt"; import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; @@ -50,16 +57,19 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpServer from "effect/unstable/http/HttpServer"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { SERVICE_STATE_FILE, @@ -68,9 +78,13 @@ import { } from "./serviceProtocol.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + decodeConfirmedOrigin, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, + decodeRuntimeConfig, encodeEndpointRuntimeConfigJson, + encodeConfirmedOriginJson, PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, @@ -85,14 +99,24 @@ import { import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; -import { filterRelayResponse, relayRequestError } from "./relayResponse.ts"; +import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; +/** Secret store name prefixes of cloud replay markers. The server prunes expired ones. */ +export const CLOUD_REPLAY_MARKER_PREFIXES = [ + CLOUD_MINT_NONCE_PREFIX, + CLOUD_MINT_JTI_PREFIX, + CLOUD_HEALTH_NONCE_PREFIX, + CLOUD_HEALTH_JTI_PREFIX, +] as const; const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; +// The desktop app stops its backends within seconds of writing the marker. +const DESKTOP_UPDATE_RESTART_MARKER_TTL = Duration.minutes(1); +const MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT = Duration.minutes(2); const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { "cache-control": "no-store", @@ -136,8 +160,9 @@ export function consumeCloudReplayGuards(input: { readonly names: ReadonlyArray; readonly value: Uint8Array; }) { - return Effect.all( - input.names.map((name) => + return Effect.forEach( + input.names, + (name) => input.secrets.create(name, input.value).pipe( Effect.as(true), Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => @@ -146,7 +171,6 @@ export function consumeCloudReplayGuards(input: { : Effect.fail(error), ), ), - ), { concurrency: input.names.length }, ).pipe(Effect.map((created) => created.every(Boolean))); } @@ -297,6 +321,33 @@ function endpointRequestPort(url: URL): number { return Number(url.port || (url.protocol === "https:" ? 443 : 80)); } +export function parseManagedEndpointLocalOrigin(localOrigin: string) { + const url = new URL(localOrigin); + if ( + localOrigin !== localOrigin.trim() || + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" || + localOrigin.includes("?") || + localOrigin.includes("#") + ) { + throw new Error("Invalid local origin"); + } + const wsUrl = new URL(url.origin); + wsUrl.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return { + httpBaseUrl: url.origin, + wsBaseUrl: wsUrl.origin, + origin: { + localHttpHost: url.hostname, + localHttpPort: endpointRequestPort(url), + } satisfies RelayManagedEndpointOrigin, + }; +} + function isAllowedEndpointOrigin(input: { readonly origin: RelayManagedEndpointOrigin; readonly requestUrl: string; @@ -361,6 +412,7 @@ interface CloudHttpDependencies { readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; + readonly awarenessRelay: AgentAwarenessRelay.AgentAwarenessRelay["Service"]; } const cloudHttpDependencies = Effect.gen(function* () { @@ -371,6 +423,7 @@ const cloudHttpDependencies = Effect.gen(function* () { environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, + awarenessRelay: yield* AgentAwarenessRelay.AgentAwarenessRelay, } satisfies CloudHttpDependencies; }); @@ -451,55 +504,250 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( ), ); -const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( +function managedEndpointRuntimeConfigsMatch( + left: RelayManagedEndpointRuntimeConfig, + right: RelayManagedEndpointRuntimeConfig, +): boolean { + return ( + left.providerKind === right.providerKind && + left.connectorToken === right.connectorToken && + left.tunnelId === right.tunnelId && + left.tunnelName === right.tunnelName + ); +} + +const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(function* ( dependencies: CloudHttpDependencies, - payload: RelayEnvironmentConfigRequest, + input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }, ) { - yield* validateRelayConfigPayload(payload); - yield* validateLinkedCloudUser({ - secrets: dependencies.secrets, - cloudUserId: payload.cloudUserId, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if (Option.isNone(currentConfig) || bytesToString(currentConfig.value) !== input.configJson) { + return null; + } + const status = yield* dependencies.endpointRuntime.applyConfig(input.config); + if (status.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: status, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: input.config, + origin: input.origin, + }); + yield* dependencies.secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return status; + }), + ); +}); + +const activateManagedTunnelWithRetry = ( + dependencies: CloudHttpDependencies, + input: { + readonly config: RelayManagedEndpointRuntimeConfig; + readonly configJson: string; + readonly origin: RelayManagedEndpointOrigin; + }, + retryRuntimeFailures: boolean, +) => { + const activate = activateManagedTunnel(dependencies, input); + return retryRuntimeFailures + ? activate.pipe( + Effect.retry({ + while: (error) => + error._tag === "EnvironmentCloudEndpointUnavailableError" && + ManagedEndpointRuntime.isRetryableManagedEndpointRuntimeStatus( + error.endpointRuntimeStatus, + ), + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }), + ) + : activate; +}; + +export const startManagedCloudTunnelIfOriginConfirmed = Effect.fn( + "environment.cloud.startManagedCloudTunnelIfOriginConfirmed", +)(function* (localOrigin: string, options?: { readonly requireConfirmedOrigin?: boolean }) { + const dependencies = yield* cloudHttpDependencies; + const requireConfirmedOrigin = options?.requireConfirmedOrigin ?? true; + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), }); - yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); - const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( - payload.endpointRuntime, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const [runtimeBytes, markerBytes] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + ]); + if (Option.isNone(runtimeBytes)) return false; + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeBytes.value))); + if (config === null || config.providerKind !== "cloudflare_tunnel") return false; + // With the marker required, only a config the relay already confirmed on + // this port may start. Without it, startup is falling back after the + // relay stayed unreachable: an unconfirmed origin may send traffic to a + // stale port, but that beats no remote access at all. + if (requireConfirmedOrigin) { + if (Option.isNone(markerBytes)) return false; + const marker = Option.getOrNull(decodeConfirmedOrigin(bytesToString(markerBytes.value))); + if ( + marker === null || + !managedEndpointRuntimeConfigsMatch(marker.config, config) || + marker.origin.localHttpHost !== parsedOrigin.origin.localHttpHost || + marker.origin.localHttpPort !== parsedOrigin.origin.localHttpPort + ) { + return false; + } + } + const status = yield* dependencies.endpointRuntime.applyConfig(config); + if (status.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: status, + }); + } + return true; + }), ); - const ok = - endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; - if (!ok) { - return yield* new EnvironmentCloudEndpointUnavailableError({ - message: "Managed endpoint runtime could not be started.", - endpointRuntimeStatus, +}); + +const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( + dependencies: CloudHttpDependencies, + payload: RelayEnvironmentConfigRequest, + options?: { + readonly lockHeld?: boolean; + readonly confirmedOrigin?: RelayManagedEndpointOrigin; + }, +) { + const apply = Effect.gen(function* () { + yield* validateRelayConfigPayload(payload); + yield* validateLinkedCloudUser({ + secrets: dependencies.secrets, + cloudUserId: payload.cloudUserId, }); - } + yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); + // Reject unsupported runtimes before touching the connector so a bad + // payload cannot stop a healthy tunnel on its way to a 503. + if ( + payload.endpointRuntime !== null && + payload.endpointRuntime.providerKind !== "cloudflare_tunnel" + ) { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus: { + status: "unsupported", + providerKind: payload.endpointRuntime.providerKind, + }, + }); + } + yield* dependencies.endpointRuntime.applyConfig(null); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); - yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); - yield* dependencies.secrets.set( - RELAY_ISSUER_SECRET, - stringToBytes(payload.relayIssuer ?? payload.relayUrl), - ); - yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); - yield* dependencies.secrets.set( - RELAY_ENVIRONMENT_CREDENTIAL_SECRET, - stringToBytes(payload.environmentCredential), - ); - yield* dependencies.secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); - if (payload.endpointRuntime) { - const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); yield* dependencies.secrets.set( - CLOUD_ENDPOINT_RUNTIME_CONFIG, - stringToBytes(endpointRuntimeJson), + RELAY_ISSUER_SECRET, + stringToBytes(payload.relayIssuer ?? payload.relayUrl), ); - } else { - yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); - } - return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); + yield* dependencies.secrets.set( + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + stringToBytes(payload.environmentCredential), + ); + yield* dependencies.secrets.set( + CLOUD_MINT_PUBLIC_KEY, + stringToBytes(payload.cloudMintPublicKey), + ); + yield* dependencies.awarenessRelay.requestCatchUp(); + if (payload.endpointRuntime) { + const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* dependencies.secrets.set( + CLOUD_ENDPOINT_RUNTIME_CONFIG, + stringToBytes(endpointRuntimeJson), + ); + } else { + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) { + return { + ok: true, + endpointRuntimeStatus: { status: "disabled" }, + } satisfies EnvironmentCloudRelayConfigResult; + } + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( + payload.endpointRuntime, + ); + if (endpointRuntimeStatus.status !== "running") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus, + }); + } + const marker = yield* encodeConfirmedOriginJson({ + config: payload.endpointRuntime, + origin: options.confirmedOrigin, + }); + yield* dependencies.secrets.set(CLOUD_ENDPOINT_CONFIRMED_ORIGIN, stringToBytes(marker)); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }); + return yield* options?.lockHeld ? apply : dependencies.endpointRuntime.withLinkStateLock(apply); }); const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { yield* requireEnvironmentScope(AuthRelayWriteScope); - return yield* applyCloudRelayConfig(dependencies, payload); + const result = yield* applyCloudRelayConfig(dependencies, payload); + if (payload.endpointRuntime?.providerKind === "cloudflare_tunnel") { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* new EnvironmentHttpInternalServerError({ + message: "Could not resolve the local server origin.", + }); + } + const registration = yield* registerManagedCloudTunnelRecovery( + `http://127.0.0.1:${address.port}`, + ).pipe( + Effect.retry({ + times: 2, + while: (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + }), + ); + if (registration.status === "superseded") { + return yield* new EnvironmentHttpConflictError({ + message: "The managed tunnel configuration changed during registration.", + }); + } + if (registration.status === "recovery_required") { + yield* dependencies.endpointRuntime.requestRecovery(registration.config); + } + if (registration.status !== "ready") { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint origin could not be confirmed.", + endpointRuntimeStatus: { status: "disabled" }, + }); + } + return { + ok: true, + endpointRuntimeStatus: registration.endpointRuntimeStatus, + } satisfies EnvironmentCloudRelayConfigResult; + } + return result; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error), @@ -508,10 +756,14 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTag( - "SchemaError", - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), + Effect.catchTags({ + SchemaError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + PlatformError: failEnvironmentCloudInternalError( + "Could not register the managed endpoint origin.", + ), + }), ); const relayClientRequest = ( @@ -521,6 +773,7 @@ const relayClientRequest = ( readonly token: string; readonly payload: unknown; readonly schema: Schema.Decoder; + readonly timeout?: Duration.Input; }, ) => HttpClientRequest.post(input.url).pipe( @@ -529,25 +782,20 @@ const relayClientRequest = ( Effect.flatMap(dependencies.httpClient.execute), Effect.flatMap(filterRelayResponse), Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), + Effect.timeout(input.timeout ?? "10 seconds"), Effect.mapError(relayRequestError), withRelayClientTracing, ); const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( function* (dependencies: CloudHttpDependencies, localOrigin: string) { - const localUrl = yield* Effect.try({ - try: () => new URL(localOrigin), + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), catch: () => new EnvironmentHttpBadRequestError({ message: "Could not resolve local environment origin.", }), }); - if (localUrl.origin !== localOrigin) { - return yield* new EnvironmentHttpBadRequestError({ - message: "Could not resolve local environment origin.", - }); - } - const localWsOrigin = localOrigin.replace(/^http/u, "ws"); const token = yield* dependencies.cliTokenManager.getExisting.pipe( Effect.flatMap( Option.match({ @@ -580,16 +828,13 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi challenge: challenge.challenge, relayIssuer: relayUrl, endpoint: { - httpBaseUrl: localOrigin, - wsBaseUrl: localWsOrigin, + httpBaseUrl: parsedOrigin.httpBaseUrl, + wsBaseUrl: parsedOrigin.wsBaseUrl, providerKind: managedTunnelsEnabled ? "cloudflare_tunnel" : "manual", }, - origin: { - localHttpHost: localUrl.hostname, - localHttpPort: endpointRequestPort(localUrl), - }, + origin: parsedOrigin.origin, }, - localOrigin, + parsedOrigin.httpBaseUrl, ); const link = yield* relayClientRequest(dependencies, { url: `${relayUrl}/v1/client/environment-links`, @@ -601,16 +846,27 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi managedTunnelsEnabled, }, schema: RelayEnvironmentLinkResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, }); yield* setCliDesiredCloudLink(true, mode); - return yield* applyCloudRelayConfig(dependencies, { - relayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }); + yield* applyCloudRelayConfig( + dependencies, + { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + { + lockHeld: true, + confirmedOrigin: parsedOrigin.origin, + }, + ); + // Callers decide on managed tunnel recovery from the mode this link + // actually used, not from a value read before the relay round trip. + return mode; }, Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -627,7 +883,260 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileDesiredLink")( function* (localOrigin: string) { - return yield* reconcileDesiredCloudLinkWith(yield* cloudHttpDependencies, localOrigin); + const dependencies = yield* cloudHttpDependencies; + return yield* dependencies.endpointRuntime.withLinkStateLock( + reconcileDesiredCloudLinkWith(dependencies, localOrigin), + ); + }, +); + +export const reconcileDesiredCloudLinkIfStillDesired = Effect.fn( + "environment.cloud.reconcileDesiredLinkIfStillDesired", +)(function* (localOrigin: string) { + const dependencies = yield* cloudHttpDependencies; + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + if (!(yield* readCliDesiredCloudLink)) { + return null; + } + return yield* reconcileDesiredCloudLinkWith(dependencies, localOrigin); + }), + ); +}); + +type ManagedTunnelRecoveryProofInput = { + readonly environmentId: RelayManagedEndpointRecoveryProofPayload["environmentId"]; + readonly cloudUserId: string; + readonly relayUrl: string; +} & ( + | { + readonly action: "register"; + readonly tunnelId: string; + readonly origin: RelayManagedEndpointOrigin; + } + | { readonly action: "recover"; readonly origin: RelayManagedEndpointOrigin } +); + +const makeManagedTunnelRecoveryProof = Effect.fn( + "environment.cloud.makeManagedTunnelRecoveryProof", +)(function* (dependencies: CloudHttpDependencies, input: ManagedTunnelRecoveryProofInput) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const configuredIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET); + const now = yield* DateTime.now; + const issuedAt = Math.floor(now.epochMilliseconds / 1_000); + const claims = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer( + Option.isSome(configuredIssuer) ? bytesToString(configuredIssuer.value) : input.relayUrl, + ), + sub: input.environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: issuedAt, + exp: issuedAt + 60, + environmentId: input.environmentId, + cloudUserId: input.cloudUserId, + }; + const payload = + input.action === "register" + ? { + ...claims, + action: "register" as const, + tunnelId: input.tunnelId, + origin: input.origin, + } + : { ...claims, action: "recover" as const, origin: input.origin }; + + return yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MANAGED_TUNNEL_RECOVERY_TYP, + payload, + }).pipe( + Effect.mapError( + () => + new EnvironmentHttpInternalServerError({ + message: "Could not sign the managed tunnel recovery request.", + }), + ), + ); +}); + +export const registerManagedCloudTunnelRecovery = Effect.fn( + "environment.cloud.registerManagedCloudTunnelRecovery", +)(function* (localOrigin: string, options?: { readonly retryRuntimeFailures?: boolean }) { + const dependencies = yield* cloudHttpDependencies; + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return { status: "not_linked" as const }; + } + + const config = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if (config?.providerKind !== "cloudflare_tunnel") { + return { status: "not_linked" as const }; + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), + }); + if (config.tunnelId === undefined) { + return { status: "recovery_required" as const, config }; + } + const origin = parsedOrigin.origin; + const environmentId = yield* dependencies.environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const proof = yield* makeManagedTunnelRecoveryProof(dependencies, { + action: "register", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + tunnelId: config.tunnelId, + origin, + }); + const registered = yield* relayClientRequest(dependencies, { + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel/recovery`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + tunnelId: config.tunnelId, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryRegistrationResponse, + }); + if (registered.status === "recovery_required") { + return { status: registered.status, config }; + } + const endpointRuntimeStatus = yield* activateManagedTunnelWithRetry( + dependencies, + { + config, + configJson: bytesToString(runtimeConfig.value), + origin, + }, + options?.retryRuntimeFailures === true, + ); + return endpointRuntimeStatus === null + ? { status: "superseded" as const } + : { status: "ready" as const, endpointRuntimeStatus }; +}); + +export const recoverManagedCloudTunnel = Effect.fn("environment.cloud.recoverManagedCloudTunnel")( + function* ( + localOrigin: string, + expectedConfig?: RelayManagedEndpointRuntimeConfig, + options?: { readonly retryRuntimeFailures?: boolean }, + ) { + const dependencies = yield* cloudHttpDependencies; + const [runtimeConfig, relayUrl, cloudUserId, environmentCredential] = yield* Effect.all([ + dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + if ( + Option.isNone(runtimeConfig) || + Option.isNone(relayUrl) || + Option.isNone(cloudUserId) || + Option.isNone(environmentCredential) + ) { + return false; + } + if (expectedConfig !== undefined) { + const current = Option.getOrNull(decodeRuntimeConfig(bytesToString(runtimeConfig.value))); + if ( + current === null || + current.providerKind !== expectedConfig.providerKind || + current.connectorToken !== expectedConfig.connectorToken || + current.tunnelId !== expectedConfig.tunnelId || + current.tunnelName !== expectedConfig.tunnelName + ) { + return false; + } + } + + const parsedOrigin = yield* Effect.try({ + try: () => parseManagedEndpointLocalOrigin(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), + }); + + const environmentId = yield* dependencies.environment.getEnvironmentId; + const relayUrlValue = bytesToString(relayUrl.value); + const cloudUserIdValue = bytesToString(cloudUserId.value); + const origin = parsedOrigin.origin; + const proof = yield* makeManagedTunnelRecoveryProof(dependencies, { + action: "recover", + environmentId, + cloudUserId: cloudUserIdValue, + relayUrl: relayUrlValue, + origin, + }); + const recovered = yield* relayClientRequest(dependencies, { + url: `${relayUrlValue}/v1/environments/${encodeURIComponent(environmentId)}/tunnel`, + token: bytesToString(environmentCredential.value), + payload: { + cloudUserId: cloudUserIdValue, + origin, + proof, + }, + schema: RelayManagedEndpointRecoveryResponse, + timeout: MANAGED_ENDPOINT_PROVISION_REQUEST_TIMEOUT, + }); + if (recovered.endpointRuntime.providerKind !== "cloudflare_tunnel") { + return yield* new EnvironmentHttpInternalServerError({ + message: "T3 Connect returned an unsupported managed tunnel configuration.", + }); + } + + const encoded = yield* encodeEndpointRuntimeConfigJson(recovered.endpointRuntime).pipe( + Effect.mapError( + () => + new EnvironmentHttpInternalServerError({ + message: "Could not persist the recovered managed tunnel configuration.", + }), + ), + ); + const stored = yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const currentConfig = yield* dependencies.secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if ( + Option.isNone(currentConfig) || + bytesToString(currentConfig.value) !== bytesToString(runtimeConfig.value) + ) { + return false; + } + yield* dependencies.secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(encoded)); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + return true; + }), + ); + if (!stored) return false; + const status = yield* activateManagedTunnelWithRetry( + dependencies, + { + config: recovered.endpointRuntime, + configJson: encoded, + origin, + }, + options?.retryRuntimeFailures === true, + ); + return status !== null; }, ); @@ -663,6 +1172,29 @@ const pendingUpdateHandoffExists = Effect.gen(function* () { return !stopping; }); +// The desktop app writes its marker right before it stops this server to +// install an update, whether a remote client or the local app started it. +// Reading consumes it, so shutdown checks it first. Only a fresh marker counts, +// so a marker the server never read (a hard kill) cannot keep the tunnel on a +// later quit. +const desktopUpdateRestartPending = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const markerPath = path.join(config.baseDir, "runtime", DESKTOP_UPDATE_RESTART_MARKER_FILE); + const marker = yield* fs.stat(markerPath).pipe(Effect.option); + if (Option.isNone(marker)) { + return false; + } + yield* fs.remove(markerPath).pipe(Effect.ignore); + const now = yield* Clock.currentTimeMillis; + return Option.match(marker.value.mtime, { + onNone: () => false, + onSome: (writtenAt) => + now - writtenAt.getTime() < Duration.toMillis(DESKTOP_UPDATE_RESTART_MARKER_TTL), + }); +}); + // Cloudflare bills per provisioned tunnel, so an environment that goes offline // must not leave its tunnel behind. Releasing deletes only the tunnel — the // relay keeps the link and its hostname reservation, and the next startup's @@ -677,24 +1209,23 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( if (Option.isNone(runtimeConfig)) { return false; } - // Only CLI-desired managed links release on shutdown, because the startup - // reconcile that provisions the replacement tunnel only runs for them. A - // link installed by a web/mobile client comes back after a restart by - // reapplying the stored connector token — it has no boot-time re-provision - // path — so its tunnel must survive the restart. (Unlink still deletes it.) + // Only CLI-desired managed links release eagerly because this request uses + // CLI authorization. Web/mobile links register startup recovery with their + // environment credential, and the relay reaper removes them after they are + // down for the configured grace period. Unlink still deletes either kind. if (!(yield* readCliDesiredCloudLink) || (yield* readCliDesiredLinkMode) !== "managed") { return false; } - // A shutdown that hands off to a pending remote update is not the - // environment going offline: the launcher immediately brings a server back - // (the new version, or the old one after a rollback). Deleting the tunnel - // here forces that server to provision a replacement UUID, and the public - // hostname's route to the new tunnel takes 1-2 minutes to propagate — the - // dominant cost of an update restart. Keep the tunnel instead: the next + // A shutdown that hands off to a pending update is not the environment + // going offline: the service launcher or the desktop app immediately brings + // a server back (the new version, or the old one after a rollback). Deleting + // the tunnel here forces that server to provision a replacement UUID, and the + // public hostname's route to the new tunnel takes 1-2 minutes to propagate — + // the dominant cost of an update restart. Keep the tunnel instead: the next // boot respawns the connector from the stored config and is reachable as // soon as it connects, and the reconcile confirms the still-live tunnel // without replacing it. - if (yield* pendingUpdateHandoffExists) { + if ((yield* desktopUpdateRestartPending) || (yield* pendingUpdateHandoffExists)) { yield* Effect.logInfo("Keeping the managed tunnel across the update restart"); return false; } @@ -738,6 +1269,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( bytesToString(storedConfig.value) === bytesToString(runtimeConfig.value) ) { yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); } return true; }); @@ -784,21 +1316,26 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( function* (dependencies: CloudHttpDependencies) { yield* requireEnvironmentScope(AuthRelayWriteScope); - const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); - yield* Effect.all( - [ - dependencies.secrets.remove(CLOUD_LINKED_USER_ID), - dependencies.secrets.remove(RELAY_URL_SECRET), - dependencies.secrets.remove(RELAY_ISSUER_SECRET), - dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), - dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), - dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), - dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), - ], - { concurrency: 7 }, + return yield* dependencies.endpointRuntime.withLinkStateLock( + Effect.gen(function* () { + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); + yield* Effect.all( + [ + dependencies.secrets.remove(CLOUD_LINKED_USER_ID), + dependencies.secrets.remove(RELAY_URL_SECRET), + dependencies.secrets.remove(RELAY_ISSUER_SECRET), + dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), + dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), + dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: 8 }, + ); + yield* setCliDesiredCloudLink(false); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }), ); - yield* setCliDesiredCloudLink(false); - return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -816,6 +1353,7 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes(String(payload.publishAgentActivity)), ); + yield* dependencies.awarenessRelay.requestCatchUp(); return yield* readCloudLinkState(dependencies); }, Effect.catchIf( diff --git a/apps/server/src/cloud/managedTunnelStartup.test.ts b/apps/server/src/cloud/managedTunnelStartup.test.ts new file mode 100644 index 000000000000..1d75f606cd2f --- /dev/null +++ b/apps/server/src/cloud/managedTunnelStartup.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./managedTunnelStartup.ts"; + +describe("managedTunnelStartupAction", () => { + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }; + + it("requests tunnel recovery only when the relay proves it is needed", () => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status: "recovery_required", config }, + }), + ).toEqual({ action: "request_recovery", config }); + }); + + it("creates a desired CLI link only when no local managed link exists", () => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status: "not_linked" }, + }), + ).toEqual({ action: "reconcile_link" }); + }); + + it.each(["ready", "unavailable"] as const)( + "does not provision after a %s registration result", + (status) => { + expect( + managedTunnelStartupAction({ + wantsCliLink: true, + registration: { status }, + }), + ).toEqual({ action: "none" }); + }, + ); +}); + +describe("retryManagedTunnelRegistration", () => { + it.effect("does not fall back or retry when registration is permanently rejected", () => + Effect.gen(function* () { + let attempts = 0; + let fallbacks = 0; + const error = yield* Effect.flip( + retryManagedTunnelRegistration( + Effect.suspend(() => { + attempts += 1; + return Effect.fail("not authorized"); + }), + () => false, + Effect.sync(() => { + fallbacks += 1; + }), + ), + ); + expect(error).toBe("not authorized"); + expect(attempts).toBe(1); + expect(fallbacks).toBe(0); + }), + ); + + it.effect("stops retrying after the retry window so startup can fall back", () => + Effect.gen(function* () { + let attempts = 0; + const registration = Effect.suspend(() => { + attempts += 1; + return Effect.fail("relay unavailable" as const); + }); + const fiber = yield* Effect.forkChild( + Effect.flip(retryManagedTunnelRegistration(registration, () => true)), + { startImmediately: true }, + ); + yield* TestClock.adjust("15 minutes"); + expect(yield* Fiber.join(fiber)).toBe("relay unavailable"); + // Capped at 30 seconds between attempts, ten minutes allows a bounded run. + expect(attempts).toBeGreaterThan(5); + expect(attempts).toBeLessThan(60); + }), + ); + + it.effect("waits for successful registration before it activates the connector", () => + Effect.gen(function* () { + const firstAttempt = yield* Deferred.make(); + let attempts = 0; + let activations = 0; + let reconciliations = 0; + const registration = Effect.suspend(() => { + attempts += 1; + if (attempts === 1) { + return Deferred.succeed(firstAttempt, undefined).pipe( + Effect.andThen(Effect.fail("relay unavailable" as const)), + ); + } + return Effect.succeed({ status: "ready" as const }); + }); + const startup = retryManagedTunnelRegistration(registration, () => true).pipe( + Effect.tap((result) => + Effect.sync(() => { + const action = managedTunnelStartupAction({ wantsCliLink: true, registration: result }); + if (action.action === "reconcile_link") { + reconciliations += 1; + } + activations += 1; + }), + ), + ); + + const fiber = yield* Effect.forkChild(startup, { startImmediately: true }); + yield* Deferred.await(firstAttempt); + expect(attempts).toBe(1); + expect(activations).toBe(0); + + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(fiber); + + expect(attempts).toBe(2); + expect(activations).toBe(1); + expect(reconciliations).toBe(0); + }), + ); +}); diff --git a/apps/server/src/cloud/managedTunnelStartup.ts b/apps/server/src/cloud/managedTunnelStartup.ts new file mode 100644 index 000000000000..511d5545a0ac --- /dev/null +++ b/apps/server/src/cloud/managedTunnelStartup.ts @@ -0,0 +1,78 @@ +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +export type ManagedTunnelRegistrationResult = + | { readonly status: "not_linked" | "ready" | "unavailable" | "superseded" } + | { + readonly status: "recovery_required"; + readonly config: RelayManagedEndpointRuntimeConfig; + }; + +export type ManagedTunnelStartupAction = + | { readonly action: "none" } + | { readonly action: "reconcile_link" } + | { + readonly action: "request_recovery"; + readonly config: RelayManagedEndpointRuntimeConfig; + }; + +export function managedTunnelStartupAction(input: { + readonly wantsCliLink: boolean; + readonly registration: ManagedTunnelRegistrationResult; +}): ManagedTunnelStartupAction { + if (input.registration.status === "recovery_required") { + return { + action: "request_recovery", + config: input.registration.config, + }; + } + if (input.wantsCliLink && input.registration.status === "not_linked") { + return { action: "reconcile_link" }; + } + return { action: "none" }; +} + +// After this window the host can start its stored connector config while +// registration keeps retrying to reconcile the origin when the relay returns. +const MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW = Duration.minutes(10); + +export const retryManagedTunnelRegistration = ( + registration: Effect.Effect, + isRetryable: (error: E) => boolean, + onRetryWindowExhausted?: Effect.Effect, +) => { + const schedule = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ); + const withinWindow = registration.pipe( + Effect.retry({ + while: isRetryable, + schedule: schedule.pipe( + Schedule.upTo({ duration: MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW }), + ), + }), + ); + if (onRetryWindowExhausted === undefined) return withinWindow; + return withinWindow.pipe( + Effect.catchIf(isRetryable, () => + onRetryWindowExhausted.pipe( + Effect.andThen(registration.pipe(Effect.retry({ while: isRetryable, schedule }))), + ), + ), + ); +}; + +// A host asks the relay for a replacement tunnel at most this often. Every +// managed host shares one relay, so a host stuck in a bad loop must not turn +// into a fleet-wide request storm. +export const MANAGED_TUNNEL_RECOVERY_COOLDOWN = Duration.minutes(2); + +// Existing hosts register on their first boot after an upgrade, and desktop +// auto-update delivers that boot to many hosts at once. Spread the first +// registration so the relay and Cloudflare see a ramp instead of a spike. +export const MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER = Duration.seconds(30); diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index e35c94545a5e..eeea28a2b68f 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -7,14 +7,14 @@ const relayClientTracingConfig = resolveRelayClientTracingConfig(); export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { - serviceName: "t3-headless-relay-client", + serviceName: "t3code-server", runtime: "node", client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { - serviceName: "t3-server", + serviceName: "t3code-server", runtime: "node", client: "environment-server", component: "relay-broker", diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 8ca0baa64a32..05c96c95c2df 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -105,7 +105,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( order.push("accept"); return "launcher-id"; })), - prepareTrial: Effect.sync((): undefined => undefined), + prepareTrial: Effect.undefined, }); const config = yield* ServerConfig.ServerConfig.pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts new file mode 100644 index 000000000000..17827bb38ba9 --- /dev/null +++ b/apps/server/src/compileCache.test.ts @@ -0,0 +1,53 @@ +// @effect-diagnostics nodeBuiltinImport:off - the test kills a real Node process to prove the cache is on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; + +it.each([false, true])( + "persists an enabled cache before forced exit (disabled: %s)", + async (disabled) => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-compile-cache-")); + try { + const cacheDirectory = NodePath.join(directory, "cache"); + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; +const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); +await Effect.runPromise(flushCompileCache); +process.kill(process.pid, "SIGKILL");`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: cacheDirectory, + NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + assert.notEqual(child.status, 0); + const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true }).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }, + ); + const files = await Promise.all( + entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), + ); + assert.equal( + files.some((entry) => entry.isFile()), + !disabled, + ); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/apps/server/src/compileCache.ts b/apps/server/src/compileCache.ts new file mode 100644 index 000000000000..d31e4305774c --- /dev/null +++ b/apps/server/src/compileCache.ts @@ -0,0 +1,9 @@ +import * as NodeModule from "node:module"; +import * as Effect from "effect/Effect"; + +// Desktop enables this cache before loading the backend. Windows force-kills +// the backend on quit, so persist it after startup instead of waiting for exit. +// This is a no-op when caching is disabled, including normal dev launches. +export const flushCompileCache = Effect.try(() => NodeModule.flushCompileCache()).pipe( + Effect.ignore, +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5762ccdb6ff3..d8dad5ae4d24 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,6 +18,7 @@ import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; export const DEFAULT_PORT = 3773; @@ -43,7 +44,6 @@ export interface ServerDerivedPaths { /** Screenshots the agent asks the collaborative browser to keep for the user. */ readonly browserArtifactsDir: string; readonly logsDir: string; - readonly serverLogPath: string; readonly serverTracePath: string; readonly providerLogsDir: string; readonly providerEventLogPath: string; @@ -81,7 +81,7 @@ export class ServerConfig extends Context.Service< readonly otlpTracesExport: SignalExport; readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; - readonly otlpServiceName: string; + readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -117,8 +117,9 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) * logs report the same service identity to the collector. */ export const otlpResource = (config: ServerConfig["Service"]) => ({ - serviceName: config.otlpServiceName, + serviceName: "t3code-server", attributes: { + "service.namespace": "t3code", "service.runtime": "t3-server", "service.mode": config.mode, }, @@ -152,7 +153,6 @@ export const deriveServerPaths = Effect.fn(function* ( attachmentsDir, browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, - serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), providerLogsDir, providerEventLogPath: join(providerLogsDir, "events.log"), @@ -220,7 +220,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index 9c5d88bd7910..dc29375f0a47 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -106,7 +106,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( ? emitStage(desktopUpdateProgressStage(report.state)).pipe( Effect.as(Option.none()), ) - : Effect.succeed(Option.some(report)), + : Effect.succeedSome(report), ), Stream.filterMap( Option.match({ diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 0f039274e207..d80b0e714645 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -111,6 +111,32 @@ describe("device hub proxy", () => { await response.text(); }); + it("reads Android fold state but requires operate scope to change it", async () => { + const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554"; + const reader = fixture([AuthOrchestrationReadScope]); + const read = await reader.handler(new Request(path)); + expect(read.status).toBe(200); + await read.text(); + expect(reader.requests).toEqual([ + "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554", + ]); + const denied = await reader.handler( + new Request(path, { method: "POST", body: '{"posture":"closed"}' }), + ); + expect(denied.status).toBe(403); + expect(reader.requests).toHaveLength(1); + + const operator = fixture([AuthOrchestrationOperateScope]); + const changed = await operator.handler( + new Request(path, { method: "POST", body: '{"posture":"closed"}' }), + ); + expect(changed.status).toBe(200); + await changed.text(); + expect(operator.requests).toEqual([ + "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554", + ]); + }); + it("never forwards the vendor shell endpoint", async () => { const { handler, requests } = fixture([AuthOrchestrationOperateScope]); expect( @@ -139,3 +165,29 @@ it.each([ expect(await response.text()).not.toContain("private credential diagnostic"); expect(requests).toEqual([]); }); + +it.each([1, 3])( + "forwards fixed Duo display %s through the authenticated read proxy", + async (panel) => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const route = `/vendor/serve-sim/helper/duo/panel/${panel}/stream.avcc`; + const response = await handler( + new Request(`http://t3.test/api/device-hub${route}?wsTicket=secret`), + ); + expect(response.status).toBe(200); + await response.text(); + expect(requests).toEqual([`http://hub.test${route}`]); + }, +); + +it.each(["/panel/2/stream.avcc", "/panel/1/webrtc/offer", "/panel/3/exec"])( + "rejects unsupported Duo route %s", + async (route) => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const response = await handler( + new Request(`http://t3.test/api/device-hub/vendor/serve-sim/helper/duo${route}`), + ); + expect(response.status).toBe(404); + expect(requests).toEqual([]); + }, +); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index dd048480b3dd..706137796654 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -42,8 +42,9 @@ const ALLOWED_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/, + /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/, /^\/vendor\/serve-sim\/appstate$/, - /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/, + /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/, /^\/vendor\/serve-emu\/health$/, ]; @@ -51,6 +52,7 @@ const ALLOWED_PATHS: ReadonlyArray = [ const MUTABLE_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/, + /^\/vendor\/serve-emu\/api\/fold$/, ]; const ALLOWED_WS_PATHS: ReadonlyArray = [ @@ -141,7 +143,7 @@ const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( pumpFrames(client, writeToUpstream), ); }), - ).pipe(Effect.catchCause(() => Effect.void)); + ).pipe(Effect.ignoreCause); return HttpServerResponse.empty(); }); @@ -203,7 +205,7 @@ const handler = Effect.gen(function* () { } const controlsDevice = (upgrade && hubPath !== "/api/devices/ws") || - (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); + (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath)); yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); const devices = yield* DeviceService.DeviceService; const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 2435fbca34ab..dcb81d95eb99 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -906,13 +906,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* lifecycleLock.withPermit( Effect.gen(function* () { if (!installTool) - return yield* Effect.fail( - new DeviceOperationError({ - operation: "update device tool", - reason: "request_failed", - cause: new Error("Tool installation is unavailable in this device service."), - }), - ); + return yield* new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }); yield* installTool(tool); return yield* inspect; }), diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index 81cdb79217fe..f30960ed2212 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -26,9 +26,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -export const DEVICE_HUB_VERSION = "0.10.1"; +export const DEVICE_HUB_VERSION = "0.12.0"; const AGENT_DEVICE_PACKAGE = "agent-device"; -export const AGENT_DEVICE_VERSION = "0.21.7"; +export const AGENT_DEVICE_VERSION = "0.21.12"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index e704d28bbc34..5d0ba5176a1e 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -326,7 +326,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } } yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - }).pipe(Effect.catchCause(() => Effect.void)); + }).pipe(Effect.ignoreCause); const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => encodeHubStateFile({ @@ -422,7 +422,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Stream.runForEach((line) => Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), ), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); /** diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts index 43cc64e8faca..0d7d61a18d59 100644 --- a/apps/server/src/device/deviceToolMaintenance.ts +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -128,9 +128,12 @@ const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( ], }); if (result.code !== 0) - return yield* Effect.fail( - new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), - ); + return yield* new DeviceToolMaintenanceError({ + operation, + tool, + exitCode: result.code, + cause: result, + }); }); export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( diff --git a/apps/server/src/diagnostics/TraceDiagnostics.test.ts b/apps/server/src/diagnostics/TraceDiagnostics.test.ts index 70bb4dc815c3..3b86841b99ba 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.test.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.test.ts @@ -7,6 +7,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Stream from "effect/Stream"; import * as TraceDiagnostics from "./TraceDiagnostics.ts"; @@ -40,72 +41,78 @@ function record(input: { }); } +const traceFilePath = "/tmp/server.trace.ndjson"; +const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); + +/** Aggregates whole lines in memory, the way diagnostics read traces before streaming. */ +function aggregateLines(lines: ReadonlyArray) { + const aggregator = TraceDiagnostics.makeTraceDiagnosticsAggregator(); + lines.forEach(aggregator.addLine); + return aggregator.finish({ + traceFilePath, + scannedFilePaths: TraceDiagnostics.toRotatedTracePaths(traceFilePath, 1), + readAt, + }); +} + +/** Reads the trace file and one rotated backup through a fake file system. */ +function readTraces(fileSystem: Partial) { + return TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt }).pipe( + Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(FileSystem.layerNoop(fileSystem)))), + ); +} + describe("TraceDiagnostics", () => { it.effect("aggregates failures, slow spans, log levels, and parse errors", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - slowSpanThresholdMs: 1_000, - files: [ - { - path: "/tmp/server.trace.ndjson.1", - text: [ - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - "not-json", - ].join("\n"), - }, - { - path: "/tmp/server.trace.ndjson", - text: [ - record({ - name: "orchestration.dispatch", - traceId: "trace-b", - spanId: "span-b", - startMs: 2_000, - durationMs: 1_500, - exit: { _tag: "Failure", cause: "Provider crashed" }, - events: [ - { - name: "provider failed", - timeUnixNano: ns(3_400), - attributes: { "effect.logLevel": "Error" }, - }, - ], - }), - record({ - name: "orchestration.dispatch", - traceId: "trace-c", - spanId: "span-c", - startMs: 4_000, - durationMs: 250, - exit: { _tag: "Failure", cause: "Provider crashed" }, - }), - record({ - name: "git.status", - traceId: "trace-d", - spanId: "span-d", - startMs: 5_000, - durationMs: 25, - exit: { _tag: "Interrupted", cause: "Interrupted" }, - events: [ - { - name: "status delayed", - timeUnixNano: ns(5_010), - attributes: { "effect.logLevel": "Warning" }, - }, - ], - }), - ].join("\n"), - }, - ], - }); + const diagnostics = aggregateLines([ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: "provider failed", + timeUnixNano: ns(3_400), + attributes: { "effect.logLevel": "Error" }, + }, + ], + }), + record({ + name: "orchestration.dispatch", + traceId: "trace-c", + spanId: "span-c", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed" }, + }), + record({ + name: "git.status", + traceId: "trace-d", + spanId: "span-d", + startMs: 5_000, + durationMs: 25, + exit: { _tag: "Interrupted", cause: "Interrupted" }, + events: [ + { + name: "status delayed", + timeUnixNano: ns(5_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); assert.equal(diagnostics.recordCount, 4); assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z"); @@ -139,47 +146,110 @@ describe("TraceDiagnostics", () => { ); it.effect("returns a not-found diagnostic when no files are available", () => - Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/missing.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [], - }); + Effect.gen(function* () { + const diagnostics = yield* readTraces({}); assert.equal(diagnostics.recordCount, 0); assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found"); }), ); - it.effect("preserves full failure causes and log messages", () => - Effect.sync(() => { - const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); - const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: record({ - name: "VcsProcess.run", - traceId: "trace-long", - spanId: "span-long", + it.effect("streams rotated files into the same result as reading them whole", () => + Effect.gen(function* () { + // CRLF and LF endings plus multi-byte text, served one byte per chunk so + // chunks split lines, line endings, and characters. + const files = new Map([ + [ + `${traceFilePath}.1`, + [ + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", startMs: 1_000, + durationMs: 50, + }), + "not-json", + record({ + name: "orchestration.dispatch", + traceId: "trace-b", + spanId: "span-b", + startMs: 2_000, + durationMs: 1_500, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + "", + ].join("\r\n"), + ], + [ + traceFilePath, + [ + record({ + name: "git.status", + traceId: "trace-c", + spanId: "span-c", + startMs: 3_000, durationMs: 25, - exit: { _tag: "Failure", cause: longCause }, + exit: { _tag: "Interrupted", cause: "Interrupted" }, events: [ { - name: longMessage, - timeUnixNano: ns(1_010), + name: "status delayed ⏳", + timeUnixNano: ns(3_010), attributes: { "effect.logLevel": "Warning" }, }, ], }), - }, + "", + record({ + name: "orchestration.dispatch", + traceId: "trace-d", + spanId: "span-d", + startMs: 4_000, + durationMs: 250, + exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" }, + }), + ].join("\n"), ], + ]); + const encoder = new TextEncoder(); + + const diagnostics = yield* readTraces({ + stream: (path) => + Stream.fromIterable( + Array.from(encoder.encode(files.get(path)), (byte) => Uint8Array.of(byte)), + ), }); + assert.equal(diagnostics.recordCount, 4); + assert.deepStrictEqual( + diagnostics, + aggregateLines([...files.values()].flatMap((text) => text.split(/\r?\n/))), + ); + }), + ); + + it.effect("preserves full failure causes and log messages", () => + Effect.sync(() => { + const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim(); + const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim(); + const diagnostics = aggregateLines([ + record({ + name: "VcsProcess.run", + traceId: "trace-long", + spanId: "span-long", + startMs: 1_000, + durationMs: 25, + exit: { _tag: "Failure", cause: longCause }, + events: [ + { + name: longMessage, + timeUnixNano: ns(1_010), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ]); + assert.equal(diagnostics.latestFailures[0]?.cause, longCause); assert.equal(diagnostics.commonFailures[0]?.cause, longCause); assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage); @@ -188,45 +258,34 @@ describe("TraceDiagnostics", () => { it.effect("keeps loaded trace data when one rotated trace file fails to read", () => Effect.gen(function* () { - const traceFilePath = "/tmp/server.trace.ndjson"; const readFailure = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", - method: "readFileString", + method: "open", description: "permission denied", pathOrDescriptor: `${traceFilePath}.1`, }); - const fileSystemLayer = FileSystem.layerNoop({ - readFileString: (path) => - path === `${traceFilePath}.1` - ? Effect.fail(readFailure) - : Effect.succeed( - record({ - name: "server.getConfig", - traceId: "trace-a", - spanId: "span-a", - startMs: 1_000, - durationMs: 50, - }), - ), - }); const logAnnotations: Array> = []; const logger = Logger.make((options) => { logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) }); }); - const diagnostics = yield* TraceDiagnostics.readTraceDiagnostics({ - traceFilePath, - maxFiles: 1, - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - }).pipe( - Effect.provide( - Layer.mergeAll( - TraceDiagnostics.layer.pipe(Layer.provide(fileSystemLayer)), - Logger.layer([logger], { mergeWithExisting: false }), - ), - ), - ); + const diagnostics = yield* readTraces({ + stream: (path) => + path === `${traceFilePath}.1` + ? Stream.fail(readFailure) + : Stream.make( + new TextEncoder().encode( + record({ + name: "server.getConfig", + traceId: "trace-a", + spanId: "span-a", + startMs: 1_000, + durationMs: 50, + }), + ), + ), + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); assert.equal(diagnostics.recordCount, 1); assert.equal( @@ -251,32 +310,43 @@ describe("TraceDiagnostics", () => { }), ); - it.effect("keeps only the slowest span occurrences while aggregating large inputs", () => + it.effect("keeps only the top spans, failures, and warning logs from large inputs", () => Effect.sync(() => { - const diagnostics = TraceDiagnostics.aggregateTraceDiagnostics({ - traceFilePath: "/tmp/server.trace.ndjson", - readAt: DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"), - files: [ - { - path: "/tmp/server.trace.ndjson", - text: Array.from({ length: 25 }, (_, index) => - record({ - name: `span-${index}`, - traceId: `trace-${index}`, - spanId: `span-${index}`, - startMs: index * 1_000, - durationMs: index, - }), - ).join("\n"), - }, - ], - }); + // Shuffled, so some older records arrive after the lists are full. + const indexes = Array.from({ length: 30 }, (_, step) => (step * 7) % 30); + const diagnostics = aggregateLines( + indexes.map((index) => + record({ + name: `span-${index}`, + traceId: `trace-${index}`, + spanId: `span-${index}`, + startMs: index * 1_000, + durationMs: index, + exit: { _tag: "Failure", cause: "Provider crashed" }, + events: [ + { + name: `warning ${index}`, + timeUnixNano: ns(index * 1_000), + attributes: { "effect.logLevel": "Warning" }, + }, + ], + }), + ), + ); + const newestTwenty = Array.from({ length: 20 }, (_, rank) => `trace-${29 - rank}`); - assert.equal(diagnostics.recordCount, 25); - assert.equal(diagnostics.slowestSpans.length, 10); + assert.equal(diagnostics.recordCount, 30); assert.deepStrictEqual( diagnostics.slowestSpans.map((span) => span.durationMs), - [24, 23, 22, 21, 20, 19, 18, 17, 16, 15], + [29, 28, 27, 26, 25, 24, 23, 22, 21, 20], + ); + assert.deepStrictEqual( + diagnostics.latestFailures.map((failure) => failure.traceId), + newestTwenty, + ); + assert.deepStrictEqual( + diagnostics.latestWarningAndErrorLogs.map((log) => log.traceId), + newestTwenty, ); }), ); diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 58b08ea8b572..6dbc4afdec93 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -16,6 +16,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; interface TraceRecordLike { readonly name?: unknown; @@ -63,16 +64,6 @@ export class TraceDiagnostics extends Context.Service< } >()("t3/diagnostics/TraceDiagnostics") {} -interface TraceDiagnosticsInput { - readonly traceFilePath: string; - readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>; - readonly scannedFilePaths?: ReadonlyArray; - readonly slowSpanThresholdMs?: number; - readonly readAt: DateTime.Utc; - readonly error?: TraceDiagnosticsErrorSummary; - readonly partialFailure?: boolean; -} - interface TraceDiagnosticsErrorSummary { readonly kind: ServerTraceDiagnosticsErrorKind; readonly message: string; @@ -81,7 +72,12 @@ interface TraceDiagnosticsErrorSummary { const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; const TOP_LIMIT = 10; const RECENT_LIMIT = 20; -function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { + +/** The trace file and its rotated backups, oldest first. */ +export function toRotatedTracePaths( + traceFilePath: string, + maxFiles: number, +): ReadonlyArray { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( { length: backupCount }, @@ -168,44 +164,50 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } -function insertBoundedSlowestSpan( - slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], - span: ServerTraceDiagnosticsSpanOccurrence, +/** + * Adds `item` to `items`, which stays sorted by `order` and holds at most + * `limit` entries. Same result as a stable sort and slice over every item, but + * memory stays bounded however many items stream in. + */ +function insertBounded( + items: A[], + item: A, + limit: number, + order: (left: A, right: A) => number, ): void { - if ( - slowestSpans.length >= TOP_LIMIT && - span.durationMs <= slowestSpans[slowestSpans.length - 1]!.durationMs - ) { + if (items.length >= limit && order(item, items[items.length - 1]!) >= 0) { return; } - slowestSpans.push(span); - slowestSpans.sort((left, right) => right.durationMs - left.durationMs); - if (slowestSpans.length > TOP_LIMIT) { - slowestSpans.length = TOP_LIMIT; + items.push(item); + items.sort(order); + if (items.length > limit) { + items.length = limit; } } -export function aggregateTraceDiagnostics( - input: TraceDiagnosticsInput, -): ServerTraceDiagnosticsResult { - const readAt = input.readAt; - const slowSpanThresholdMs = input.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; - const scannedFilePaths = input.scannedFilePaths ?? input.files.map((file) => file.path); - if (input.files.length === 0) { - return makeEmptyDiagnostics({ - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - slowSpanThresholdMs, - error: input.error ?? { - kind: "trace-file-not-found", - message: "No local trace files were found.", - }, - ...(input.partialFailure ? { partialFailure: true } : {}), - }); - } - +const slowestFirst = ( + left: ServerTraceDiagnosticsSpanOccurrence, + right: ServerTraceDiagnosticsSpanOccurrence, +) => right.durationMs - left.durationMs; + +const latestEndedFirst = ( + left: ServerTraceDiagnosticsRecentFailure, + right: ServerTraceDiagnosticsRecentFailure, +) => DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt); + +const latestSeenFirst = ( + left: ServerTraceDiagnosticsLogEvent, + right: ServerTraceDiagnosticsLogEvent, +) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt); + +/** + * Folds trace NDJSON into diagnostics. Call `addLine` once per line as the + * rotated files stream in, then `finish` for the result. + */ +export function makeTraceDiagnosticsAggregator( + slowSpanThresholdMs = DEFAULT_SLOW_SPAN_THRESHOLD_MS, +) { let parseErrorCount = 0; let recordCount = 0; let failureCount = 0; @@ -224,182 +226,184 @@ export function aggregateTraceDiagnostics( const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = []; const logLevelCounts: Record = {}; - for (const file of input.files) { - const lines = file.text.split(/\r?\n/); - for (const line of lines) { - if (line.trim().length === 0) continue; - - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; - } + const addLine = (line: string) => { + if (line.trim().length === 0) return; - if (!isRecordObject(parsed)) { - parseErrorCount += 1; - continue; - } + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + parseErrorCount += 1; + return; + } - const name = toStringValue(parsed.name); - const traceId = toStringValue(parsed.traceId); - const spanId = toStringValue(parsed.spanId); - const durationMs = toNumberValue(parsed.durationMs); - const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); - const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); + if (!isRecordObject(parsed)) { + parseErrorCount += 1; + return; + } - if (!name || !traceId || !spanId || durationMs === null || !endedAt) { - parseErrorCount += 1; - continue; - } + const name = toStringValue(parsed.name); + const traceId = toStringValue(parsed.traceId); + const spanId = toStringValue(parsed.spanId); + const durationMs = toNumberValue(parsed.durationMs); + const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); + const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); - recordCount += 1; - firstSpanAt = - startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) - ? startedAt - : firstSpanAt; - lastSpanAt = - lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; - - const exitTag = readExitTag(parsed.exit); - const isFailure = exitTag === "Failure"; - const isInterrupted = exitTag === "Interrupted"; - if (isFailure) failureCount += 1; - if (isInterrupted) interruptionCount += 1; - - const spanSummary = spansByName.get(name) ?? { - count: 0, - failureCount: 0, - totalDurationMs: 0, - maxDurationMs: 0, - }; - spanSummary.count += 1; - spanSummary.totalDurationMs += durationMs; - spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); - if (isFailure) spanSummary.failureCount += 1; - spansByName.set(name, spanSummary); - - const spanItem = { name, durationMs, endedAt, traceId, spanId }; - if (durationMs >= slowSpanThresholdMs) { - slowSpanCount += 1; - } - insertBoundedSlowestSpan(slowestSpans, spanItem); - - if (isFailure) { - const cause = readExitCause(parsed.exit); - latestFailures.push({ ...spanItem, cause }); - - const failureKey = `${name}\0${cause}`; - const existing = failuresByKey.get(failureKey); - const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); - failuresByKey.set(failureKey, { - name, - cause, - count: (existing?.count ?? 0) + 1, - lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, - traceId: isLatestFailure ? traceId : existing!.traceId, - spanId: isLatestFailure ? spanId : existing!.spanId, - }); - } + if (!name || !traceId || !spanId || durationMs === null || !endedAt) { + parseErrorCount += 1; + return; + } - if (Array.isArray(parsed.events)) { - for (const rawEvent of parsed.events) { - if (!isTraceEvent(rawEvent)) continue; - const attributes = readEventAttributes(rawEvent); - const level = toStringValue(attributes["effect.logLevel"]); - if (!level) continue; - - logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; - const normalizedLevel = level.toLowerCase(); - if ( - normalizedLevel !== "warning" && - normalizedLevel !== "warn" && - normalizedLevel !== "error" && - normalizedLevel !== "fatal" - ) { - continue; - } - - const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; - const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; - latestWarningAndErrorLogs.push({ - spanName: name, - level, - message, - seenAt, - traceId, - spanId, - }); + recordCount += 1; + firstSpanAt = + startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) + ? startedAt + : firstSpanAt; + lastSpanAt = + lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; + + const exitTag = readExitTag(parsed.exit); + const isFailure = exitTag === "Failure"; + const isInterrupted = exitTag === "Interrupted"; + if (isFailure) failureCount += 1; + if (isInterrupted) interruptionCount += 1; + + const spanSummary = spansByName.get(name) ?? { + count: 0, + failureCount: 0, + totalDurationMs: 0, + maxDurationMs: 0, + }; + spanSummary.count += 1; + spanSummary.totalDurationMs += durationMs; + spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); + if (isFailure) spanSummary.failureCount += 1; + spansByName.set(name, spanSummary); + + const spanItem = { name, durationMs, endedAt, traceId, spanId }; + if (durationMs >= slowSpanThresholdMs) { + slowSpanCount += 1; + } + insertBounded(slowestSpans, spanItem, TOP_LIMIT, slowestFirst); + + if (isFailure) { + const cause = readExitCause(parsed.exit); + insertBounded(latestFailures, { ...spanItem, cause }, RECENT_LIMIT, latestEndedFirst); + + const failureKey = `${name}\0${cause}`; + const existing = failuresByKey.get(failureKey); + const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); + failuresByKey.set(failureKey, { + name, + cause, + count: (existing?.count ?? 0) + 1, + lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, + traceId: isLatestFailure ? traceId : existing!.traceId, + spanId: isLatestFailure ? spanId : existing!.spanId, + }); + } + + if (Array.isArray(parsed.events)) { + for (const rawEvent of parsed.events) { + if (!isTraceEvent(rawEvent)) continue; + const attributes = readEventAttributes(rawEvent); + const level = toStringValue(attributes["effect.logLevel"]); + if (!level) continue; + + logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; + const normalizedLevel = level.toLowerCase(); + if ( + normalizedLevel !== "warning" && + normalizedLevel !== "warn" && + normalizedLevel !== "error" && + normalizedLevel !== "fatal" + ) { + continue; } + + const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; + const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + insertBounded( + latestWarningAndErrorLogs, + { spanName: name, level, message, seenAt, traceId, spanId }, + RECENT_LIMIT, + latestSeenFirst, + ); } } - } - - const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] - .map(([name, span]) => ({ - name, - count: span.count, - failureCount: span.failureCount, - totalDurationMs: span.totalDurationMs, - averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, - maxDurationMs: span.maxDurationMs, - })) - .toSorted((left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs) - .slice(0, TOP_LIMIT); + }; - return { - traceFilePath: input.traceFilePath, - scannedFilePaths, - readAt, - recordCount, - parseErrorCount, - firstSpanAt: Option.fromNullishOr(firstSpanAt), - lastSpanAt: Option.fromNullishOr(lastSpanAt), - failureCount, - interruptionCount, - slowSpanThresholdMs, - slowSpanCount, - logLevelCounts, - topSpansByCount, - slowestSpans, - commonFailures: [...failuresByKey.values()] - .toSorted( - (left, right) => - right.count - left.count || - DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), - ) - .slice(0, TOP_LIMIT), - latestFailures: latestFailures - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt), - ) - .slice(0, RECENT_LIMIT), - latestWarningAndErrorLogs: latestWarningAndErrorLogs + const finish = (input: { + readonly traceFilePath: string; + readonly scannedFilePaths: ReadonlyArray; + readonly readAt: DateTime.Utc; + readonly error?: TraceDiagnosticsErrorSummary; + readonly partialFailure?: boolean; + }): ServerTraceDiagnosticsResult => { + const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] + .map(([name, span]) => ({ + name, + count: span.count, + failureCount: span.failureCount, + totalDurationMs: span.totalDurationMs, + averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, + maxDurationMs: span.maxDurationMs, + })) .toSorted( - (left, right) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt), + (left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs, ) - .slice(0, RECENT_LIMIT), - partialFailure: input.partialFailure ? Option.some(true) : Option.none(), - error: Option.fromNullishOr(input.error), + .slice(0, TOP_LIMIT); + + return { + traceFilePath: input.traceFilePath, + scannedFilePaths: input.scannedFilePaths, + readAt: input.readAt, + recordCount, + parseErrorCount, + firstSpanAt: Option.fromNullishOr(firstSpanAt), + lastSpanAt: Option.fromNullishOr(lastSpanAt), + failureCount, + interruptionCount, + slowSpanThresholdMs, + slowSpanCount, + logLevelCounts, + topSpansByCount, + slowestSpans, + commonFailures: [...failuresByKey.values()] + .toSorted( + (left, right) => + right.count - left.count || + DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), + ) + .slice(0, TOP_LIMIT), + latestFailures, + latestWarningAndErrorLogs, + partialFailure: input.partialFailure ? Option.some(true) : Option.none(), + error: Option.fromNullishOr(input.error), + }; }; -} -type TraceFileReadResult = - | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } - | { readonly _tag: "Missing"; readonly path: string }; + return { addLine, finish }; +} -function readTraceFile( +/** + * Feeds each line of one trace file to `onLine`, streaming so only one chunk of + * text is in memory at a time. Succeeds with false when the file does not exist. + */ +export function streamTraceFileLines( fileSystem: FileSystem.FileSystem, path: string, -): Effect.Effect { - return fileSystem.readFileString(path).pipe( - Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })), + onLine: (line: string) => void, +): Effect.Effect { + return fileSystem.stream(path).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEachArray((lines) => Effect.sync(() => lines.forEach(onLine))), + Effect.as(true), Effect.catchTags({ PlatformError: (cause) => isNotFoundError(cause) - ? Effect.succeed({ _tag: "Missing", path }) + ? Effect.succeed(false) : Effect.fail( new TraceFileReadError({ traceFilePath: path, @@ -420,9 +424,11 @@ export const make = Effect.gen(function* () { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); - const results = yield* Effect.all( - paths.map((path) => - readTraceFile(fileSystem, path).pipe( + const aggregator = makeTraceDiagnosticsAggregator(slowSpanThresholdMs); + const results = yield* Effect.forEach( + paths, + (path) => + streamTraceFileLines(fileSystem, path, aggregator.addLine).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to read local trace file.").pipe( Effect.annotateLogs({ @@ -434,16 +440,10 @@ export const make = Effect.gen(function* () { ), Effect.result, ), - ), - { - concurrency: 1, - }, - ); - const files = results.flatMap((result) => - Result.isSuccess(result) && result.success._tag === "Loaded" - ? [{ path: result.success.path, text: result.success.text }] - : [], + // Every file feeds one aggregator, so read them one at a time, oldest first. + { concurrency: 1 }, ); + const foundFile = results.some((result) => Result.isSuccess(result) && result.success); const readFailure = results.find(Result.isFailure); const readFailureError = readFailure ? ({ @@ -452,7 +452,7 @@ export const make = Effect.gen(function* () { } satisfies TraceDiagnosticsErrorSummary) : undefined; - if (files.length === 0) { + if (!foundFile) { return makeEmptyDiagnostics({ traceFilePath: options.traceFilePath, scannedFilePaths: paths, @@ -467,12 +467,10 @@ export const make = Effect.gen(function* () { }); } - return aggregateTraceDiagnostics({ + return aggregator.finish({ traceFilePath: options.traceFilePath, - files, scannedFilePaths: paths, readAt, - slowSpanThresholdMs, ...(readFailureError ? { partialFailure: true, error: readFailureError } : {}), }); }, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b4758e980065..6eb95c84b477 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -6,11 +6,11 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -34,7 +34,7 @@ const makeServerEnvironmentLayer = (baseDir: string) => const emptySecretStoreLayer = Layer.succeed( ServerSecretStore.ServerSecretStore, ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, set: () => Effect.void, create: () => Effect.void, getOrCreateRandom: () => Effect.succeed(new Uint8Array()), @@ -59,7 +59,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a36c0a03b6f2..d58c014d86e8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -127,13 +127,12 @@ const makeIdentity = Effect.gen(function* () { }); yield* fileSystem.writeFileString(tempPath, `${value}\n`); // Publish the completed file without replacing an ID created by another process. - yield* fileSystem - .link(tempPath, destinationPath) - .pipe( - Effect.catch((cause) => - cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), - ), - ); + yield* fileSystem.link(tempPath, destinationPath).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); if (mode === "recover") { // Keep the recovery ID so delayed initializers also publish the same winner. yield* fileSystem.remove(tempPath); @@ -236,6 +235,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadActiveReorder: true, + threadAutoSettleOptOut: true, threadTitleRegeneration: true, threadPullRequests: true, pullRequestStackActions: true, diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts index 1d944492331a..4a5d0a009b9a 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -128,7 +128,7 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( cause, }), ), - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ ServerEnvironmentLabelCommandError: (error) => Effect.logDebug(error.message).pipe( diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index 9d11a1ef59fd..e9f5e51af600 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -107,7 +107,7 @@ const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.readFileString(path).pipe( Effect.map(normalize), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); }); @@ -125,7 +125,7 @@ const runProbe = Effect.fn("runMachineProbe")(function* (input: { }) .pipe( Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); }); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e7aeab087728..115455b3bf56 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { @@ -1346,7 +1347,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/deleted-fork-branch": JSON.stringify([ + "feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", @@ -1376,8 +1377,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-05T15:00:00.000Z", }); expect( - ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")), ).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); }), ); @@ -1528,6 +1530,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); + }), + ); + it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1758,8 +1815,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - "alice:feature": output, - "fork:feature": output, feature: output, }, }, @@ -1926,10 +1981,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -1965,7 +2016,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1997,7 +2048,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:main": JSON.stringify([ + main: JSON.stringify([ { number: 777, title: "Fork PR from main", @@ -2031,7 +2082,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2096,34 +2147,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), }, }, }); @@ -2334,7 +2357,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-plain": JSON.stringify([ + "feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", @@ -2354,9 +2377,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); - expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( - true, - ); + expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); @@ -2391,7 +2413,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-settle": JSON.stringify([ + "feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", @@ -3552,7 +3574,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", + "returns existing cross-repo PR metadata found under the bare branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3571,8 +3593,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -3604,7 +3624,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.includes("pr list --head statemachine --state open --limit 100"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -3670,30 +3690,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), }, }, }); @@ -3713,7 +3709,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + "picks the fork PR among same-named branches from other repositories", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3745,9 +3741,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3764,8 +3757,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), }, }, }); @@ -3777,11 +3768,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect( + ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), + ).toBe(false); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -3810,7 +3799,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ + statemachine: JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3828,11 +3817,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), }, }, }); @@ -3845,10 +3830,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + "pr list --head statemachine --state open --limit 100", ); }), 12_000, @@ -3873,12 +3858,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": ["[]"], - statemachine: ["[]"], }, }, }); @@ -4109,7 +4092,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4141,7 +4124,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4149,6 +4140,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4162,6 +4155,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); @@ -4262,7 +4260,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -4284,10 +4282,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], }, }, }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f09ddb59e679..4ebfce0feb21 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, + type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; @@ -144,6 +145,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -588,6 +595,26 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -1106,7 +1133,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, @@ -1597,12 +1626,14 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: 1, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1627,12 +1658,13 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: 20, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { @@ -2066,7 +2098,7 @@ export const make = Effect.gen(function* () { title: generated.title, bodyFile, }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore))); const created = yield* findOpenPr(cwd, headContext); if (!created) { diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index 2c33d82792bd..2e8be05febe7 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -83,7 +83,7 @@ const makeDependencies = ( Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getProjectShellById: () => Effect.succeedSome(project), }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 7533b8c1db19..30b368e62167 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -311,9 +311,12 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; - readonly bodyJson: OtlpTracer.TraceData; }> {} +// Renderers export up to once a second while they have spans buffered, so +// tracing this proxy would add more server spans than it forwards. +// withTracerEnabled(false) drops the handler's spans, including the forward. +// untracedRequestsLayer drops the HTTP server span. export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -330,15 +333,10 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( yield* Effect.try({ try: () => decodeOtlpTraceRecords(bodyJson), - catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), + catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }), }).pipe( Effect.flatMap((records) => browserTraceCollector.record(records)), - Effect.catch((cause) => - Effect.logWarning("Failed to decode browser OTLP traces", { - cause, - bodyJson, - }), - ), + Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })), ); if (otlpTracesUrl === undefined) { @@ -369,9 +367,25 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( EnvironmentInternalError: HttpServerRespondable.toResponse, EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, }), + Effect.withTracerEnabled(false), ), ); +const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_PATH]); + +// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts +// before routing, so a route handler cannot skip it. TracerDisabledWhen is one +// predicate for the whole server and the last layer to provide it wins, so +// makeRoutesLayer provides this one last. Add paths here instead of providing +// TracerDisabledWhen again; server.test.ts fails if a later layer replaces it. +// The query string is ignored, as in routing. +export const untracedRequestsLayer = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { + const queryIndex = request.url.indexOf("?"); + return UNTRACED_REQUEST_PATHS.has( + queryIndex === -1 ? request.url : request.url.slice(0, queryIndex), + ); +}); + export const assetRouteLayer = HttpRouter.add( "GET", `${ASSET_ROUTE_PREFIX}/*`, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ec7070809435..fb256f25500a 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -567,13 +567,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); yield* Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; - yield* Effect.all( - commands.map((command, index) => + yield* Effect.forEach( + commands, + (command, index) => keybindings.upsertKeybindingRule({ key: `mod+${String.fromCharCode(97 + index)}`, command, }), - ), { concurrency: "unbounded", discard: true }, ); }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 15518e47a497..5619f19b2549 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -56,7 +55,7 @@ const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive. Layer.provide( Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getThreadShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, }), Layer.mock(OrchestrationEngineService)({}), NodeServices.layer, @@ -162,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])( Effect.provideService(McpSchema.McpServerClient, client), ); + const message = "Preview automation snapshot failed on client mcp-failure-client."; expect(snapshot.isError).toBe(true); expect(snapshot.content).toEqual([ - { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, + { type: "text", text: `Preview snapshot failed: ${message}` }, ]); expect(snapshot.structuredContent).toEqual({ error: { _tag: "PreviewAutomationExecutionError", operation: "snapshot", failureCount: 1, + message, }, }); }), ).pipe(Effect.provide(TestLayer)), ); +it.effect.each([ + { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, + { + args: { tabId: alternateTabId }, + advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, + }, +])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Deferred.succeed(connected, undefined) + : broker.respond({ + clientId: "mcp-no-tab-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: false, + error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + + const snapshot = yield* callSnapshot(args); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([ + { type: "text", text: `Preview snapshot failed: ${advice}` }, + ]); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () => + Effect.gen(function* () { + const snapshot = yield* callSnapshot({}); + + expect(snapshot.isError).toBe(true); + const [text] = snapshot.content; + expect(text?.type === "text" ? text.text : "").toContain( + "use a headless browser from the shell", + ); + expect(snapshot.structuredContent).toMatchObject({ + error: { _tag: "PreviewAutomationNoAvailableHostError" }, + }); + }).pipe(Effect.provide(TestLayer)), +); + it.effect.each([ { mode: "default", input: {}, images: true }, { mode: "explicit image", input: { includeImage: true }, images: true }, @@ -249,7 +300,10 @@ it.effect.each([ const metadata = { ...page, title: `Snapshot ${call}`, screenshot }; const { accessibilityTree: _tree, ...boundedMetadata } = metadata; expect(snapshot.isError).toBe(false); - expect(snapshot.structuredContent).toEqual(metadata); + expect(snapshot.structuredContent).toEqual({ + ...boundedMetadata, + omitted: ["accessibilityTree (use interactiveElements locators or preview_evaluate)"], + }); const [identity, text, ...rest] = snapshot.content; expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ url: page.url, @@ -288,7 +342,8 @@ it.effect.each([ "text", "image", ]); - expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); + expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot }); + expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree"); expect(requests).toBe(7); }), ).pipe(Effect.provide(TestLayer)), @@ -342,6 +397,15 @@ it.effect("saves the snapshot PNG on request and reports its path", () => const unsaved = yield* callSnapshot({}); expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); + + // A save without the image skips the page dump. + const pathOnly = yield* callSnapshot({ save: true, includeImage: false }); + const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; + expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); + expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png"); + const [only, ...others] = pathOnly.content; + expect(others).toEqual([]); + expect(only?.type === "text" ? decodeJsonText(only.text) : null).toEqual(saved); }), ).pipe(Effect.provide(TestLayer)), ); @@ -461,9 +525,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); - // The structured result is untouched; only the text the agent reads is bounded. - expect(snapshot.structuredContent).toMatchObject({ - accessibilityTree: oversized.accessibilityTree, + // Claude Code shows the model structuredContent instead of the text, so it is bounded too. + expect(snapshot.structuredContent).toEqual({ + ...parsed, + omitted: expect.arrayContaining(["60 older console entries"]), }); }), ).pipe(Effect.provide(TestLayer)), @@ -501,6 +566,45 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are ).pipe(Effect.provide(TestLayer)), ); +it.effect("bounds page text made of wide characters before dropping locators", () => + Effect.scoped( + Effect.gen(function* () { + // The character caps alone leave 8,000 three-byte characters, about 24 KB. + yield* serveSnapshots("mcp-wide-text-client", { + ...snapshotResult, + visibleText: "界".repeat(9_000), + interactiveElements: Array.from({ length: 20 }, (_, i) => ({ + tag: "button", + role: "button", + name: `Button ${i}`, + selector: `#button-${i}`, + x: 0, + y: 0, + width: 10, + height: 10, + })), + }); + + const snapshot = yield* callSnapshot({ includeImage: false }); + + const [, text, notice] = snapshot.content; + const body = text?.type === "text" ? text.text : ""; + expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( + McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, + ); + const parsed = decodeJsonText(body) as { + readonly visibleText: string; + readonly interactiveElements: ReadonlyArray; + }; + expect(parsed.visibleText).toMatch(/^界+…$/); + expect(parsed.interactiveElements).toHaveLength(20); + expect(notice?.type === "text" ? notice.text : "").toContain( + "visibleText after 4000 characters", + ); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("sheds log entries before locators when every list is full", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 52130d69fa55..9ac823674952 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { PreviewAutomationError } from "@t3tools/contracts"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; @@ -114,12 +115,15 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ }>()(makeMcpAuthMiddleware).layer; /** - * Claude Code drops every MCP result above 25k tokens (~100 KB of text) and - * hands the agent a truncation notice instead, so a snapshot that carries the - * full accessibility tree and 20 KB of page text loses its locators too. Keep - * the text under that ceiling and tell the agent what was cut. + * Claude Code moves an MCP result above its output limit to a file and hands + * the agent a notice instead, so a snapshot that carries the full + * accessibility tree and page text loses its locators too. Claude Code also + * shows the model `structuredContent` in place of the text blocks when a + * result has both, so both carry the same bounded snapshot. Keep it near + * 20 KB and tell the agent what was cut. The short `omitted` notes may go a + * little over; the provider limit is far above this. */ -export const MAX_SNAPSHOT_TEXT_BYTES = 60_000; +export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; const MAX_SNAPSHOT_LOG_ENTRIES = 40; @@ -164,12 +168,11 @@ type SnapshotMetadata = { /** * Drops the accessibility tree, shortens page text, element names, identifiers, * and log strings, keeps only the newest log entries, and finally sheds - * interactive elements until the JSON fits. Returns the text plus notes on - * what is missing so the agent can reach for preview_evaluate. + * interactive elements until the JSON fits. Returns the bounded value, its + * text, and notes on what is missing so the agent can reach for + * preview_evaluate. */ -const boundSnapshotMetadata = ( - metadata: SnapshotMetadata, -): { readonly text: string; readonly omitted: ReadonlyArray } => { +const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { const omitted: Array = []; const { accessibilityTree, ...withoutTree } = metadata; if (accessibilityTree !== undefined) { @@ -198,16 +201,10 @@ const boundSnapshotMetadata = ( ) { omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`); } - if (metadata.visibleText.length > MAX_SNAPSHOT_VISIBLE_TEXT_CHARS) { - omitted.push( - `visibleText after ${MAX_SNAPSHOT_VISIBLE_TEXT_CHARS} characters (use preview_evaluate for more)`, - ); - } const bounded = { ...withoutTree, url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS), - visibleText: cutText(metadata.visibleText, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS), interactiveElements: metadata.interactiveElements.map((element) => ({ ...element, name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS), @@ -218,9 +215,10 @@ const boundSnapshotMetadata = ( }; // Per-field caps do not sum below the ceiling: three log arrays of 40 capped - // entries alone can pass 60 KB. Shed the least useful lists first, halving - // one list per round, until the JSON fits. With every list empty the rest - // is bounded by the identifier and visibleText caps, so this terminates. + // entries alone can pass 60 KB, and the caps count characters, not bytes. + // Halve one thing per round until the JSON fits: logs first, then page + // text, then the locators. The identifier caps bound the rest, so this + // terminates. const shedOrder = [ "actionTimeline", "networkEntries", @@ -239,31 +237,51 @@ const boundSnapshotMetadata = ( networkEntries: 0, actionTimeline: 0, }; - let text = encodeJsonText({ ...bounded, ...lists }); + let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS); + const value = () => ({ + ...bounded, + visibleText: cutText(metadata.visibleText, visibleTextChars), + ...lists, + }); + let text = encodeJsonText(value()); while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { // Elements carry the locators, so they go last; logs shed newest-last. const key = shedOrder.find( (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, - ) ?? (lists.interactiveElements.length > 0 ? "interactiveElements" : undefined); + ) ?? + (visibleTextChars > 0 + ? "visibleText" + : lists.interactiveElements.length > 0 + ? "interactiveElements" + : undefined); if (key === undefined) break; - const keep = Math.floor(lists[key].length / 2); - dropped[key] += lists[key].length - keep; - // slice(-0) keeps everything, so spell out the empty case. - lists[key] = - keep === 0 - ? [] - : key === "interactiveElements" - ? lists[key].slice(0, keep) - : lists[key].slice(-keep); - text = encodeJsonText({ ...bounded, ...lists }); + if (key === "visibleText") { + visibleTextChars = Math.floor(visibleTextChars / 2); + } else { + const keep = Math.floor(lists[key].length / 2); + dropped[key] += lists[key].length - keep; + // slice(-0) keeps everything, so spell out the empty case. + lists[key] = + keep === 0 + ? [] + : key === "interactiveElements" + ? lists[key].slice(0, keep) + : lists[key].slice(-keep); + } + text = encodeJsonText(value()); + } + if (visibleTextChars < metadata.visibleText.length) { + omitted.push( + `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`, + ); } for (const key of shedOrder) { if (dropped[key] > 0) { omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); } } - return { text, omitted }; + return { value: value(), text, omitted }; }; export class PreviewScreenshotSaveError extends Schema.TaggedError()( @@ -311,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); +const isPreviewAutomationError = Schema.is(PreviewAutomationError); + const previewSnapshotFailure = (cause: Cause.Cause) => { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); @@ -324,6 +344,9 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { typeof firstFailure._tag === "string" ? firstFailure._tag : "PreviewSnapshotError"; + // Preview errors build their message on the server, never from page output, + // and it tells the agent what to do next, such as falling back to a shell browser. + const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, structuredContent: { @@ -331,10 +354,11 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { _tag: errorTag, operation: "snapshot", failureCount: failures.length, + ...(message === undefined ? {} : { message }), }, }, - // Agents usually see only the text content, so name the tag there too. - content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], + // Some clients show only the text content and others only structuredContent, so both carry it. + content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }], }); return Effect.logWarning("preview snapshot failed", { operation: "snapshot", @@ -396,6 +420,18 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; + if (screenshotPath !== undefined && payload?.includeImage === false) { + // The agent only wants a file to show the user. The url keeps the site icon on the tool row. + const saved = { + url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), + screenshotPath, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: saved, + content: [{ type: "text", text: encodeJsonText(saved) }], + }); + } const metadata = { ...page, screenshot: { @@ -408,7 +444,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const bounded = boundSnapshotMetadata(metadata); return new McpSchema.CallToolResult({ isError: false, - structuredContent: metadata, + structuredContent: + bounded.omitted.length === 0 + ? bounded.value + : { ...bounded.value, omitted: bounded.omitted }, content: [ // Keep the page identity readable even if a provider truncates the snapshot. { diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 123944206e37..4314c82e6ddc 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav providerSessionId: invocation.providerSessionId, providerInstanceId: invocation.providerInstanceId, }); - expect(error.message).toBe("MCP credential does not grant the preview capability."); + expect(error.message).toContain("MCP credential does not grant the preview capability"); + expect(error.message).toContain("use a headless browser from the shell"); }); }); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index eddfa7270a77..96ddd8f7bfc1 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -47,9 +47,11 @@ const missingCapability = ( export const requireMcpCapability = ( capability: C, ): Effect.Effect, McpInvocationContext> => - Effect.flatMap(McpInvocationContext, (invocation) => - invocation.capabilities.has(capability) - ? Effect.succeed(invocation) - : // The conditional type narrows what the literal argument decided at runtime. - Effect.fail(missingCapability(invocation, capability) as McpCapabilityError), - ).pipe(Effect.withSpan("mcp.requireCapability")); + McpInvocationContext.pipe( + Effect.filterOrFail( + (invocation) => invocation.capabilities.has(capability), + // The conditional type narrows what the literal argument decided at runtime. + (invocation) => missingCapability(invocation, capability) as McpCapabilityError, + ), + Effect.withSpan("mcp.requireCapability"), + ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index fa0a17d826f9..50441df96a63 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -230,7 +230,7 @@ export const issueActiveMcpCredential = ( ? activeMcpSessionRegistry .revokeThread(request.threadId) .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) - : Effect.sync((): McpIssuedCredential | undefined => undefined); + : Effect.undefined; /** * Refreshes the liveness of a thread's MCP credential. Called on every provider diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index ef552f9b04d0..7a8ed739d734 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -725,6 +726,130 @@ it.effect("pins a provider session to its initial host despite later focus chang ), ); +it.effect("prefers the live tab owner for new sessions without moving existing leases", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connections = new Map(); + for (const clientId of ["owner", "other"]) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId })), + (connectionId) => connections.set(clientId, connectionId), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + ], + }); + yield* broker.focusHost({ + clientId: "other", + environmentId: scope.environmentId, + connectionId: connections.get("other")!, + focused: true, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { + threadId: ThreadId.make("another-thread"), + tabId: PreviewTabId.make("different-tab"), + visible: true, + }, + ], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "explicit-owner" }, + tabId: PreviewTabId.make("signed-in"), + operation: "snapshot", + input: {}, + }), + ).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "other-tab" }, + tabId: PreviewTabId.make("different-tab"), + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "after-tab-closed" }, + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + }), + ), +); + +it.effect("prefers a focused host over unrelated extra capabilities for a new session", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let focusedConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["focused", ["status"]], + ["background", ["status", "resize"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "focused") focusedConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "focused", + environmentId: scope.environmentId, + connectionId: focusedConnectionId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "focused", + ); + }), + ), +); + it.effect("does not route new operations to legacy hosts that did not advertise support", () => Effect.scoped( Effect.gen(function* () { @@ -921,6 +1046,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 527f71f5915d..65e3064f49a5 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -73,6 +73,7 @@ interface ClientConnection { readonly environmentId: PreviewAutomationHost["environmentId"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; + readonly liveTabs: NonNullable; readonly focusOrder: number; readonly queue: Queue.Queue; } @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { environmentId: host.environmentId, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, + liveTabs: [], focusOrder: 0, queue, }; @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { clients.set(host.clientId, { ...currentHost, focused: host.focused, + liveTabs: host.liveTabs ?? currentHost.liveTabs, focusOrder: host.focused ? focusSequence : currentHost.focusOrder, }); return { ...current, clients, focusSequence }; @@ -492,6 +495,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // operation is not silently moved to a newer client: the caller gets a // capability failure and can deliberately start a fresh provider // session. A dead lease is pruned above and may fail over. + const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => + host.liveTabs.some( + (tab) => + tab.threadId === input.scope.threadId && + (!visibleOnly || tab.visible === true) && + (input.tabId === undefined || tab.tabId === input.tabId), + ); const connection = hasLiveAssignment && supportsOperation(assignedConnection, input.operation) ? assignedConnection @@ -505,7 +515,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { ) .sort( (left, right) => - right.supportedOperations.size - left.supportedOperations.size || + Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || + Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, )[0]; diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index d2c23860c9b7..b6a8ed3d6323 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -59,7 +59,7 @@ export function agentDeviceQuickStart( ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, ` ${executable} install ${target}`, `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, - "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.", + "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.", "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", platformNotes, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index caa4cbd157cf..b70e68862683 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -87,7 +87,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( updateCurrentTab: false, ...(statusTabId === undefined ? {} : { tabId: statusTabId }), }) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); return { result, ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096 @@ -175,10 +175,11 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( yield* fileSystem.rename(currentPath, finalPath); }).pipe( // Another stop may already have claimed this exact upload for this thread. - Effect.catch((cause) => - cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound" - ? validateFile(finalPath) - : Effect.fail(cause), + Effect.catchIf( + (cause) => + cause._tag !== "PreviewAutomationRecordingTransferError" && + cause.reason._tag === "NotFound", + () => validateFile(finalPath), ), Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })), ); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3f80e84e9a59..1c790e8b643a 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -119,7 +119,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", parameters: Schema.Struct({ ...PreviewAutomationTabTargetInput.fields, includeImage: Schema.optional( @@ -131,7 +131,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( save: Schema.optional( Schema.Boolean.annotate({ description: - "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Defaults to false.", + "Write the screenshot PNG to disk and return its absolute path as screenshotPath. With includeImage=false, return only the url and screenshotPath. Defaults to false.", }), ), }), diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts new file mode 100644 index 000000000000..fbe30157aec2 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -0,0 +1,77 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import { type EventLoopReadings, layerWith, stallMs } from "./EventLoopMonitor.ts"; + +const ms = (value: number) => value * 1e6; + +// Node reports a stall of S as a gap of up to S + 1 s, the histogram resolution. +const stalled: EventLoopReadings = { + delayMaxNs: ms(5_950), + activeMs: 6_200, + utilization: 0.176, + usage: { + userCPUTime: 310_400, + systemCPUTime: 95_600, + majorPageFault: 8_412, + minorPageFault: 20_031, + involuntaryContextSwitches: 57, + }, + rssBytes: 1536 * 1024 * 1024, +}; +// Over the threshold as read, but not once the resolution is subtracted. +const quiet: EventLoopReadings = { ...stalled, delayMaxNs: ms(2_950) }; + +describe("EventLoopMonitor", () => { + it.effect("records a warning span only for samples that saw a stall", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + // The first sample covers startup, so the monitor discards it. + const samples = [stalled, quiet, stalled]; + + yield* Effect.gen(function* () { + yield* Layer.build(layerWith(Effect.succeed(Effect.sync(() => samples.shift() ?? quiet)))); + yield* TestClock.adjust("60 seconds"); + assert.lengthOf(spans, 0); + yield* TestClock.adjust("30 seconds"); + }).pipe(Effect.scoped, Effect.withTracer(tracer)); + + assert.deepStrictEqual( + spans.map((span) => span.name), + ["server.eventLoop.stall"], + ); + const [span] = spans; + assert.deepStrictEqual(Object.fromEntries(span!.attributes), { + delayMaxMs: 4_950, + utilization: 0.18, + cpuUserMs: 310, + cpuSystemMs: 96, + majorPageFaults: 8_412, + minorPageFaults: 20_031, + involuntaryContextSwitches: 57, + rssMb: 1536, + }); + assert.deepStrictEqual( + span!.events.map(([name, , attributes]) => [name, attributes["effect.logLevel"]]), + [["event loop stalled for 4950 ms", "WARN"]], + ); + }), + ); + + it("ignores delay the loop spent idle, such as a system sleep", () => { + // Waking from sleep reads as a long gap, but the loop was idle in poll for it. + const asleep: EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), activeMs: 900 }; + assert.isUndefined(stallMs(asleep)); + assert.strictEqual(stallMs({ ...asleep, activeMs: 600_000 }), 599_000); + }); +}); diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts new file mode 100644 index 000000000000..13b7b48c4cf7 --- /dev/null +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -0,0 +1,135 @@ +// @effect-diagnostics nodeBuiltinImport:off - only node:perf_hooks exposes the event loop delay histogram. +import * as NodePerfHooks from "node:perf_hooks"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +// Node's delay histogram wakes a native timer every RESOLUTION_MS and records the +// gap between wakeups, so an idle loop reads about RESOLUTION_MS and a stall of S +// reads between S and S + RESOLUTION_MS. We subtract the resolution, so a delay can +// undercount a stall by up to RESOLUTION_MS. With these values every stall over 3 s +// is caught, at 1 wakeup per second that never enters JS. +const RESOLUTION_MS = 1000; +const STALL_THRESHOLD_MS = 2000; +const SAMPLE_INTERVAL = "30 seconds"; + +/** One sample interval as Node reports it. Delay in ns, active time in ms, CPU in µs. */ +export interface EventLoopReadings { + readonly delayMaxNs: number; + readonly activeMs: number; + readonly utilization: number; + readonly usage: Pick< + NodeJS.ResourceUsage, + | "userCPUTime" + | "systemCPUTime" + | "majorPageFault" + | "minorPageFault" + | "involuntaryContextSwitches" + >; + readonly rssBytes: number; +} + +// Enables the delay histogram for the layer's lifetime. Each read returns the +// readings since the previous read and resets the histogram. Node skips the first +// gap after a reset, so a stall right at a sample boundary can be missed. +const makeNodeSampler = Effect.gen(function* () { + const histogram = yield* Effect.acquireRelease( + Effect.sync(() => { + const histogram = NodePerfHooks.monitorEventLoopDelay({ resolution: RESOLUTION_MS }); + histogram.enable(); + return histogram; + }), + (histogram) => Effect.sync(() => histogram.disable()), + ); + let elu = NodePerfHooks.performance.eventLoopUtilization(); + let usage = process.resourceUsage(); + + // @effect-diagnostics-next-line returnEffectInGen:off - the read effect is the result. + return Effect.sync(() => { + const nextElu = NodePerfHooks.performance.eventLoopUtilization(); + const nextUsage = process.resourceUsage(); + const loop = NodePerfHooks.performance.eventLoopUtilization(nextElu, elu); + const readings: EventLoopReadings = { + delayMaxNs: histogram.max, + activeMs: loop.active, + utilization: loop.utilization, + usage: { + userCPUTime: nextUsage.userCPUTime - usage.userCPUTime, + systemCPUTime: nextUsage.systemCPUTime - usage.systemCPUTime, + majorPageFault: nextUsage.majorPageFault - usage.majorPageFault, + minorPageFault: nextUsage.minorPageFault - usage.minorPageFault, + involuntaryContextSwitches: + nextUsage.involuntaryContextSwitches - usage.involuntaryContextSwitches, + }, + rssBytes: process.memoryUsage.rss(), + }; + histogram.reset(); + elu = nextElu; + usage = nextUsage; + return readings; + }); +}); + +/** + * Returns the stall to report for one sample in ms, or undefined when there was none. + */ +export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { + const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; + // A stall is time the loop spent running code, so it counts as active time. libuv's + // clock keeps running while the system sleeps on macOS and Windows, so a sleep also + // reads as delay, but the loop spent it idle in poll. + if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; + return delayMs; +}; + +/** + * Samples event loop health every 30 s and records a `server.eventLoop.stall` span + * with a warning when the loop stalled for more than 2 s, so stalls land in + * the local trace file and Settings > Diagnostics without OTLP. Takes the sampler + * so tests can inject readings. + */ +export const layerWith = ( + makeSampler: Effect.Effect, never, Scope.Scope>, +) => + Layer.effectDiscard( + Effect.gen(function* () { + const sample = yield* makeSampler; + const tick = Effect.gen(function* () { + const readings = yield* sample; + const delayMaxMs = stallMs(readings); + if (delayMaxMs === undefined) return; + const { utilization, usage, rssBytes } = readings; + // Root, as the stall has no caller to attach to. Warn level keeps it when + // T3CODE_TRACE_MIN_LEVEL is raised to cut trace noise. + yield* Effect.logWarning(`event loop stalled for ${delayMaxMs} ms`).pipe( + Effect.withSpan("server.eventLoop.stall", { + root: true, + level: "Warn", + attributes: { + delayMaxMs, + utilization: Math.round(utilization * 100) / 100, + cpuUserMs: Math.round(usage.userCPUTime / 1000), + cpuSystemMs: Math.round(usage.systemCPUTime / 1000), + majorPageFaults: usage.majorPageFault, + minorPageFaults: usage.minorPageFault, + involuntaryContextSwitches: usage.involuntaryContextSwitches, + rssMb: Math.round(rssBytes / 1024 / 1024), + }, + }), + ); + }); + const wait = Effect.sleep(SAMPLE_INTERVAL); + // The layer builds before the rest of the server, so the first sample covers + // startup work such as migrations and projection bootstrap. That can block the + // loop for seconds on a large database, so skip it rather than warn at every + // launch. Layers build outside any span, so this fiber retains no parent span. + yield* wait.pipe( + Effect.andThen(sample), + Effect.andThen(wait.pipe(Effect.andThen(tick), Effect.forever)), + Effect.forkScoped, + ); + }), + ); + +export const layer = layerWith(makeNodeSampler); diff --git a/apps/server/src/observability/HeapSnapshot.test.ts b/apps/server/src/observability/HeapSnapshot.test.ts new file mode 100644 index 000000000000..0beb466e5019 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.test.ts @@ -0,0 +1,36 @@ +// @effect-diagnostics nodeBuiltinImport:off - tests fake a failed write at the native v8 boundary. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { vi } from "vite-plus/test"; + +import { writeHeapSnapshot } from "./HeapSnapshot.ts"; + +vi.mock("node:v8", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeHeapSnapshot: vi.fn(actual.writeHeapSnapshot) }; +}); + +it.layer(NodeServices.layer)("writeHeapSnapshot", (it) => { + it.effect("removes the partial file when the write fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const logsDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-heap-snapshot-test-" }); + let partialPath: string | undefined; + vi.mocked(NodeV8.writeHeapSnapshot).mockImplementationOnce((path) => { + partialPath = path; + if (path) NodeFS.writeFileSync(path, "partial"); + throw new Error("ENOSPC: no space left on device"); + }); + + yield* writeHeapSnapshot(logsDir); + + assert.strictEqual(NodePath.dirname(partialPath ?? ""), logsDir); + assert.deepEqual(yield* fs.readDirectory(logsDir), []); + }), + ); +}); diff --git a/apps/server/src/observability/HeapSnapshot.ts b/apps/server/src/observability/HeapSnapshot.ts new file mode 100644 index 000000000000..827dfab2c6d6 --- /dev/null +++ b/apps/server/src/observability/HeapSnapshot.ts @@ -0,0 +1,52 @@ +// @effect-diagnostics nodeBuiltinImport:off - v8.writeHeapSnapshot has no Effect equivalent. +import * as NodePath from "node:path"; +import * as NodeV8 from "node:v8"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; + +/** + * Writes one V8 heap snapshot into `logsDir` and logs its path. A failed write + * logs a warning and removes any partial file, because that file can hold + * secrets and the failure is often a full disk. + */ +export const writeHeapSnapshot = Effect.fn("server.heapSnapshot", { root: true })( + function* (logsDir: string) { + const fs = yield* FileSystem.FileSystem; + const timestamp = DateTime.formatIso(yield* DateTime.now).replaceAll(":", "-"); + const path = NodePath.join(logsDir, `server-${process.pid}-${timestamp}.heapsnapshot`); + yield* Effect.annotateCurrentSpan({ path }); + yield* Effect.try(() => NodeV8.writeHeapSnapshot(path)).pipe( + Effect.tapError(() => fs.remove(path, { force: true }).pipe(Effect.ignore)), + ); + yield* Effect.logInfo("Wrote heap snapshot.", { path }); + }, + Effect.catch((cause) => Effect.logWarning("Failed to write heap snapshot.", { cause })), +); + +/** + * Writes a heap snapshot when the process gets SIGUSR2 (`kill -USR2 `), + * so a maintainer can see what a long-running server holds. See "Heap + * Snapshots" in docs/operations/observability.md. + * + * The write blocks the event loop, so two snapshots never overlap: a signal + * sent during a write waits until it finishes. Windows has no SIGUSR2, so the + * layer does nothing there. + */ +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const { logsDir } = yield* ServerConfig.ServerConfig; + const runFork = Effect.runForkWith(yield* Effect.context()); + const onSignal = () => void runFork(writeHeapSnapshot(logsDir)); + yield* Effect.acquireRelease( + Effect.sync(() => process.on("SIGUSR2", onSignal)), + () => Effect.sync(() => process.off("SIGUSR2", onSignal)), + ); + }), +); diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8f7b607745f4..77ebe8410a91 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -4,6 +4,7 @@ import { makeTraceSink, otlpSerializationLayer, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as References from "effect/References"; @@ -20,6 +21,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const traces = config.otlpTracesExport; const metrics = config.otlpMetricsExport; // The trace serializer stays in the returned context because the browser @@ -86,6 +88,18 @@ export const ObservabilityLive = Layer.unwrap( resource, }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); - return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); + // Logged once the server's loggers are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe( + Layer.provideMerge( + Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer), + ), + Layer.provide( + OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), + ), + ); }), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 4cfdccfd8f74..7814a9eec9e6 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -97,12 +97,12 @@ const make = Effect.gen(function* () { const entryRefreshWorker = yield* makeDrainableWorker((cwd: string) => Effect.sync(() => queuedEntryRefreshes.delete(cwd)).pipe( Effect.andThen(workspaceEntries.refresh(cwd)), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to refresh checkpoint workspace entries", { - cwd, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("failed to refresh checkpoint workspace entries", { + cwd, + }), ), ), ); @@ -625,15 +625,14 @@ const make = Effect.gen(function* () { branch: checkedOutBranch, }); }).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("failed to follow worktree branch drift", { - threadId: input.threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("failed to follow worktree branch drift", { + threadId: input.threadId, + cause: Cause.pretty(cause), + }), + ), ); }); @@ -643,12 +642,12 @@ const make = Effect.gen(function* () { const statusRefreshWorker = yield* makeDrainableWorker( (event: Extract) => refreshLocalGitStatusFromTurnCompletion(event).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to refresh git status after turn completion", { - threadId: event.threadId, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("failed to refresh git status after turn completion", { + threadId: event.threadId, + }), ), ), ); @@ -749,11 +748,7 @@ const make = Effect.gen(function* () { for (const candidate of paths) { const otherCwd = yield* fileSystem .realPath(candidate) - .pipe( - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), - ), - ); + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(null))); if (otherCwd === null) continue; const isWithin = (parent: string, child: string) => { const relative = path.relative(parent, child); @@ -791,7 +786,7 @@ const make = Effect.gen(function* () { preferSessionRuntime: true, }).pipe( Effect.catch((error) => - event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error), + event.payload.restoreFiles === false ? Effect.undefined : Effect.fail(error), ), ); @@ -1014,16 +1009,15 @@ const make = Effect.gen(function* () { const processInputSafely = (input: ReactorInput) => processInput(input).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("checkpoint reactor failed to process input", { - source: input.source, - eventType: input.event.type, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("checkpoint reactor failed to process input", { + source: input.source, + eventType: input.event.type, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker(processInputSafely); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 078750967471..9ccf78ca1744 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -435,6 +435,7 @@ describe("OrchestrationEngine", () => { updatedAt: projectionSnapshot.updatedAt, }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence, @@ -446,18 +447,18 @@ describe("OrchestrationEngine", () => { Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, + getProjectShellById: () => Effect.succeedNone, getProjectShells: () => Effect.succeed([]), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index fb2fadde5e63..9136d080c1c3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -400,7 +400,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { status: "rejected", error: error.message, }) - .pipe(Effect.catch(() => Effect.void)); + .pipe(Effect.ignore); } } diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d25c442ac8b2..207ad9a20d34 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -108,6 +108,7 @@ describe("OrchestrationReactor", () => { Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => { started.push("agent-awareness-relay"); return Effect.void; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 844a32f062a2..b8a1268b1814 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -21,6 +21,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -114,6 +115,66 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cleanup-span-")))( + "OrchestrationProjectionPipeline attachment cleanup span", + (it) => { + it.effect("runs attachment cleanup only for events that remove attachments", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + let cleanupSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "applyAttachmentSideEffects") cleanupSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-cleanup-span"); + const threadId = ThreadId.make("thread-cleanup-span"); + + const projectCreated = yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-cleanup-span-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-project"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + projectId, + title: "Cleanup span project", + workspaceRoot: "/tmp/project-cleanup-span", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionPipeline.projectEvent(projectCreated).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 0); + + const threadDeleted = yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.make("evt-cleanup-span-thread-delete"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-cleanup-span-thread-delete"), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + yield* projectionPipeline.projectEvent(threadDeleted).pipe(Effect.withTracer(tracer)); + assert.strictEqual(cleanupSpans, 1); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( "imported thread shell projection", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 414c8f3f2363..280acb18fc9a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -670,6 +670,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pinnedAt: null, pinOrderKey: null, activeOrderKey: null, + autoSettleDisabledAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -819,6 +820,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.auto-settle-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + autoSettleDisabledAt: event.payload.autoSettleDisabledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.pin-reordered": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -2012,13 +2028,6 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")( function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) { - if ( - sideEffects.deletedThreadIds.size === 0 && - sideEffects.prunedThreadRelativePaths.size === 0 - ) { - return; - } - const deletedThreadIds = new Set(); for (const threadId of sideEffects.deletedThreadIds) { const recreatedLater = yield* eventStore.hasEventAfter({ @@ -2190,9 +2199,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }), ); + const hasCleanup = + attachmentSideEffects.deletedThreadIds.size > 0 || + attachmentSideEffects.prunedThreadRelativePaths.size > 0; // Return the cleanup effect so the caller runs it after the outer transaction commits. + // Most events have no cleanup, so they skip the call and write no cleanup span. // @effect-diagnostics-next-line returnEffectInGen:off - return applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid); + return hasCleanup + ? applyAttachmentSideEffects(event, attachmentSideEffects).pipe(Effect.asVoid) + : Effect.void; }, Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ff5ed5ffa34b..1ed3ea97a721 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -30,6 +30,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; +import { readSweepSnapshot } from "../ThreadPullRequestReactor.ts"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -485,6 +486,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, deletedAt: null, @@ -611,6 +613,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", activeOrderKey: "hq", + autoSettleDisabledAt: null, titleRegeneration: null, titleState: null, session: { @@ -3507,6 +3510,224 @@ it.effect("omits foreign-host PRs from legacy snapshots while preserving native }).pipe(Effect.provide(layer)); }); +it.effect( + "lists linked threads like the shell snapshot, in one query and without identities", + () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at, archived_at, deleted_at, settled_override, settled_at) + VALUES + ('t-late', 'p1', 'Late', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z', NULL, NULL, 'settled', '2026-09-04T00:00:00Z'), + ('t-early', 'p2', 'Early', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-first', 'p1', 'First', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-plain', 'p1', 'Plain', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL, NULL, NULL), + ('t-archived', 'p1', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-05T00:00:00Z', NULL, NULL, NULL), + ('t-deleted', 'p1', 'Deleted', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z', NULL, '2026-09-05T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES + ('t-late', 'github.com', 'acme/web', 3, 'https://github.com/acme/web/pull/3', 'manual', '2026-09-03T00:00:00Z', NULL), + ('t-early', 'github.com', 'acme/api', 4, 'https://github.com/acme/api/pull/4', 'agent', '2026-09-01T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 2, 'https://github.com/acme/web/pull/2', 'stack-dismissed', '2026-09-02T00:00:00Z', NULL), + ('t-first', 'github.com', 'acme/web', 1, 'https://github.com/acme/web/pull/1', 'created', '2026-09-02T00:00:00Z', + '{"state":"open","title":"One","headBranch":"one","baseBranch":"main","isDraft":false,"updatedAt":null,"syncedAt":"2026-09-02T00:00:00Z"}'), + ('t-archived', 'github.com', 'acme/web', 5, 'https://github.com/acme/web/pull/5', 'manual', '2026-09-02T00:00:00Z', NULL), + ('t-deleted', 'github.com', 'acme/web', 6, 'https://github.com/acme/web/pull/6', 'manual', '2026-09-02T00:00:00Z', NULL)`; + const expected = (yield* query.getShellSnapshot()).threads + .filter((thread) => thread.pullRequests.length > 0) + .map(({ id, projectId, settledOverride, settledAt, pullRequests }) => ({ + id, + projectId, + settledOverride, + settledAt, + pullRequests, + })); + resolved.length = 0; + + const counter = makeSqlStatementCounter(); + const threads = yield* query + .listThreadsWithPullRequests() + .pipe(Effect.withTracer(counter.tracer)); + assert.deepStrictEqual( + threads.map((thread) => [thread.id, thread.pullRequests.map((link) => link.number)]), + [ + ["t-first", [1, 2]], + ["t-late", [3]], + ["t-early", [4]], + ], + ); + assert.deepStrictEqual(threads, expected); + assert.strictEqual(counter.count(), 1); + assert.deepStrictEqual(resolved, []); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("reads one sweep thread and its projects like the shell snapshot", () => { + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, snoozed_until, snoozed_at, created_at, updated_at, settled_override, settled_at) + VALUES + ('t-linked', 'p1', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', '/one/wt', NULL, 'turn-1', '2026-09-02T00:00:00Z', 1, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', NULL, NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'other', NULL, + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, NULL, 0, '2026-09-10T00:00:00Z', '2026-09-02T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z', 'settled', '2026-09-03T00:00:00Z'), + ('t-other', 'p3', 'Other', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-linked', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, started_at, completed_at, checkpoint_files_json) + VALUES ('t-linked', 'turn-1', 'completed', '2026-09-02T00:00:00Z', '2026-09-02T00:00:01Z', '2026-09-02T00:00:02Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-linked', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:03Z')`; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 9, '2026-09-02T00:00:03Z')`; + } + + const full = yield* query.getShellSnapshot(); + // The seeded fields must reach the snapshot, or the parity check is empty. + const linked = full.threads.find((thread) => thread.id === ThreadId.make("t-linked")); + assert.strictEqual(full.snapshotSequence, 9); + assert.strictEqual(linked?.linkedPullRequest?.number, 7); + assert.strictEqual(linked?.latestTurn?.turnId, asTurnId("turn-1")); + assert.strictEqual(linked?.session?.status, "ready"); + + for (const [threadId, projectIds] of [ + [ThreadId.make("t-linked"), [asProjectId("p1")]], + // Settlement also needs the project that the saved branch PR names. + [ThreadId.make("t-branch"), [asProjectId("p1"), asProjectId("p2")]], + ] as const) { + assert.deepStrictEqual(yield* readSweepSnapshot(query, threadId), { + snapshotSequence: full.snapshotSequence, + projects: full.projects.filter((project) => projectIds.includes(project.id)), + threads: full.threads.filter((thread) => thread.id === threadId), + }); + } + }).pipe(Effect.provide(layer)); +}); + +it.effect("reads a full sweep from unsettled threads and every project", () => { + const resolved: string[] = []; + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: (root) => + Effect.sync(() => { + resolved.push(root); + return null; + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('p1', 'One', '/one', '[]', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z'), + ('p2', 'Two', '/two', '[]', '2026-09-02T00:00:00Z', '2026-09-02T00:00:00Z'), + ('p3', 'Three', '/three', '[]', '2026-09-03T00:00:00Z', '2026-09-03T00:00:00Z'), + ('p4', 'Four', '/four', '[]', '2026-09-04T00:00:00Z', '2026-09-04T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch_pull_request_json, latest_turn_id, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('t-open', 'p1', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-open', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-resumed', 'p1', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'active', NULL), + ('t-branch', 'p1', 'Branch', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', + '{"projectId":"p2","repository":"acme/web","number":8,"url":"https://github.com/acme/web/pull/8"}', + NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, NULL, NULL), + ('t-settled', 'p3', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, 'turn-settled', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', NULL, 'settled', '2026-09-03T00:00:00Z'), + ('t-archived', 'p4', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z', '2026-09-04T00:00:00Z', NULL, NULL)`; + // The open and the settled thread both have a row in each joined table. The + // settled thread's turn and session are the newest rows, so updatedAt shows + // whether those two reads skip it. + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('t-open', 'github.com', 'acme/web', 7, 'https://github.com/acme/web/pull/7', 'agent', '2026-09-02T00:00:00Z'), + ('t-settled', 'github.com', 'acme/web', 9, 'https://github.com/acme/web/pull/9', 'agent', '2026-09-02T00:00:00Z')`; + yield* sql`INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, checkpoint_files_json) + VALUES ('t-open', 'turn-open', 'completed', '2026-09-02T00:00:00Z', '[]'), + ('t-settled', 'turn-settled', 'completed', '2026-09-09T00:00:00Z', '[]')`; + yield* sql`INSERT INTO projection_thread_sessions (thread_id, status, provider_name, active_turn_id, last_error, updated_at) + VALUES ('t-open', 'ready', 'codex', NULL, NULL, '2026-09-02T00:00:00Z'), + ('t-settled', 'stopped', 'codex', NULL, NULL, '2026-09-10T00:00:00Z')`; + + const full = yield* query.getShellSnapshot(); + // The settled thread's rows must reach the full read, or skipping them proves nothing. + const settled = full.threads.find((thread) => thread.id === ThreadId.make("t-settled")); + assert.strictEqual(settled?.pullRequests[0]?.number, 9); + assert.strictEqual(settled?.latestTurn?.turnId, asTurnId("turn-settled")); + assert.strictEqual(settled?.session?.status, "stopped"); + assert.strictEqual(full.updatedAt, "2026-09-10T00:00:00Z"); + resolved.length = 0; + + const sweep = yield* readSweepSnapshot(query, null); + assert.strictEqual(sweep.snapshotSequence, full.snapshotSequence); + assert.deepStrictEqual( + sweep.threads, + full.threads.filter((thread) => thread.id !== ThreadId.make("t-settled")), + ); + assert.deepStrictEqual( + sweep.threads.map((thread) => thread.id), + ["t-branch", "t-open", "t-resumed"], + ); + // Like the full read, the sweep resolves every project, so it keeps the + // repository identity cache warm for client connects. + assert.deepStrictEqual(sweep.projects, full.projects); + assert.deepStrictEqual(resolved.toSorted(), ["/four", "/one", "/three", "/two"]); + // A settled thread's link that no longer decodes breaks the full read, but + // not the sweep, which never reads it. + yield* sql`UPDATE projection_thread_pull_requests SET snapshot_json = 'invalid-json' WHERE thread_id = 't-settled'`; + assert.strictEqual((yield* Effect.exit(query.getShellSnapshot()))._tag, "Failure"); + const unsettled = yield* query.getShellSnapshot({ unsettledOnly: true }); + assert.deepStrictEqual(unsettled.threads, sweep.threads); + assert.strictEqual(unsettled.updatedAt, "2026-09-04T00:00:00Z"); + }).pipe(Effect.provide(layer)); +}); + projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => { it.effect("lists one kind across active threads only, without hydrating the threads", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3bb620973cae..ea818ac13938 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,7 +12,7 @@ import { OrchestrationProposedPlanId, OrchestrationReadModel, OrchestrationThreadSearchSource, - OrchestrationShellSnapshot, + type OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, @@ -77,11 +77,11 @@ import { type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionThreadDetailQuery, + type ProjectionThreadPullRequests, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); -const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( Schema.fromJsonString( @@ -185,6 +185,7 @@ const EventReplayStatsRowSchema = Schema.Struct({ eventCount: Schema.Number, payloadBytes: Schema.Number, }); +const ActiveThreadRowsRequest = Schema.Struct({ unsettledOnly: Schema.Boolean }); const ProjectionThreadSearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int, @@ -627,6 +628,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -639,10 +641,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Background sweeps skip settled threads, the same check PR discovery makes. + const unsettledThreadsFilter = (unsettledOnly: boolean) => + unsettledOnly + ? sql`AND threads.settled_at IS NULL AND threads.settled_override IS NOT 'settled'` + : sql``; + const listActiveThreadRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadDbRowSchema, - execute: () => + execute: (request) => sql` SELECT thread_id AS "threadId", @@ -668,6 +676,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -675,9 +684,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", deleted_at AS "deletedAt" - FROM projection_threads + FROM projection_threads threads WHERE deleted_at IS NULL AND archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -741,6 +751,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -816,9 +827,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadPullRequestRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadPullRequestDbRowSchema, - execute: () => + execute: (request) => sql` SELECT links.thread_id AS "threadId", @@ -835,10 +846,46 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = links.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC `, }); + // One row per link, in the shell snapshot's thread order and link order. + const listActiveThreadPullRequestSyncRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema.mapFields( + Struct.assign({ + projectId: ProjectionThread.fields.projectId, + settledOverride: ProjectionThread.fields.settledOverride, + settledAt: ProjectionThread.fields.settledAt, + }), + ), + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + threads.project_id AS "projectId", + threads.settled_override AS "settledOverride", + threads.settled_at AS "settledAt", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY threads.project_id ASC, threads.created_at ASC, threads.thread_id ASC, + links.linked_at ASC, links.number ASC + `, + }); + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadPullRequestDbRowSchema, @@ -924,9 +971,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveThreadSessionRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionThreadSessionDbRowSchema, - execute: () => + execute: (request) => sql` SELECT sessions.thread_id AS "threadId", @@ -944,6 +991,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY sessions.thread_id ASC `, }); @@ -1018,9 +1066,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const listActiveLatestTurnRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: ActiveThreadRowsRequest, Result: ProjectionLatestTurnDbRowSchema, - execute: () => + execute: (request) => sql` SELECT turns.thread_id AS "threadId", @@ -1039,6 +1087,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL AND threads.latest_turn_id IS NOT NULL + ${unsettledThreadsFilter(request.unsettledOnly)} ORDER BY turns.thread_id ASC `, }); @@ -1321,6 +1370,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -2408,6 +2458,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2653,6 +2704,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, deletedAt: row.deletedAt, @@ -2680,8 +2732,9 @@ pending_approval_requests AS ( }), ); - const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () => - sql + const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = (options) => { + const unsettledOnly = options?.unsettledOnly === true; + return sql .withTransaction( Effect.all([ listProjectRows(undefined).pipe( @@ -2692,7 +2745,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadRows(undefined).pipe( + listActiveThreadRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", @@ -2700,7 +2753,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadSessionRows(undefined).pipe( + listActiveThreadSessionRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", @@ -2708,7 +2761,7 @@ pending_approval_requests AS ( ), ), ), - listActiveThreadPullRequestRows(undefined).pipe( + listActiveThreadPullRequestRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", @@ -2716,7 +2769,7 @@ pending_approval_requests AS ( ), ), ), - listActiveLatestTurnRows(undefined).pipe( + listActiveLatestTurnRows({ unsettledOnly }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query", @@ -2771,7 +2824,10 @@ pending_approval_requests AS ( ); const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); - const snapshot = { + // Built from schema-decoded rows, so no second decode here. The HTTP + // and RPC layers encode it against OrchestrationShellSnapshot on the + // way out, like the per-item shells from getThreadShellById. + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null @@ -2809,6 +2865,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -2824,15 +2881,7 @@ pending_approval_requests AS ( : Result.failVoid, ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { @@ -2842,6 +2891,36 @@ pending_approval_requests AS ( return toPersistenceSqlError("ProjectionSnapshotQuery.getShellSnapshot:query")(error); }), ); + }; + + const listThreadsWithPullRequests: ProjectionSnapshotQueryShape["listThreadsWithPullRequests"] = + () => + listActiveThreadPullRequestSyncRows(undefined).pipe( + Effect.map((rows) => { + const threads = new Map< + ThreadId, + ProjectionThreadPullRequests & { readonly pullRequests: Array } + >(); + for (const row of rows) { + const thread = threads.get(row.threadId) ?? { + id: row.threadId, + projectId: row.projectId, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + pullRequests: [], + }; + thread.pullRequests.push(mapPullRequestRow(row)); + threads.set(row.threadId, thread); + } + return [...threads.values()]; + }), + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.listThreadsWithPullRequests:query", + "ProjectionSnapshotQuery.listThreadsWithPullRequests:decodeRows", + ), + ), + ); const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () => sql @@ -2936,7 +3015,7 @@ pending_approval_requests AS ( sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), ); - const snapshot = { + return { snapshotSequence: computeSnapshotSequence(stateRows), projects: Arr.filterMap(projectRows, (row) => row.deletedAt === null && activeProjectIds.has(row.projectId) @@ -2972,6 +3051,7 @@ pending_approval_requests AS ( pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, activeOrderKey: row.activeOrderKey ?? null, + autoSettleDisabledAt: row.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(row), titleState: row.titleState, session: sessionByThread.get(row.threadId) ?? null, @@ -2985,15 +3065,7 @@ pending_approval_requests AS ( planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), })), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; - - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", - ), - ), - ); + } satisfies OrchestrationShellSnapshot; }), ), Effect.mapError((error) => { @@ -3328,6 +3400,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -3623,6 +3696,7 @@ pending_approval_requests AS ( pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, activeOrderKey: threadRow.value.activeOrderKey ?? null, + autoSettleDisabledAt: threadRow.value.autoSettleDisabledAt ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), titleState: threadRow.value.titleState, deletedAt: null, @@ -3829,6 +3903,7 @@ pending_approval_requests AS ( listActivitiesByKind, getSnapshot, getShellSnapshot, + listThreadsWithPullRequests, getArchivedShellSnapshot, getDeletedWorktreeThreads, searchThreads, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d9417bc75b..87f9bd03d46c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import { import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../../terminal/Manager.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => { }), ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void); const createWorktree = vi.fn( (input: { readonly refName: string; readonly path: string | null }) => Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), @@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), + Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + closeIdleTerminals, refreshStatus, generateBranchName, generateThreadTitle, @@ -887,8 +891,92 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); }); + effectIt.effect("forwards only a user-renamed title when starting a provider session", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initialTitle: "Add a progressive blur as you scroll" }), + ); + const now = "2026-01-01T00:00:00.000Z"; + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + const startTurn = (threadId: string, text: string, titleSeed: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-title-${threadId}`), + threadId: ThreadId.make(threadId), + message: { + messageId: asMessageId(`message-${threadId}`), + role: "user", + text, + attachments: [], + }, + titleSeed, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* startTurn( + "thread-1", + "Add a progressive blur as you scroll", + "Add a progressive blur as you scroll", + ); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 1)); + expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("title"); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-renamed"), + threadId: ThreadId.make("thread-renamed"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-rename"), + threadId: ThreadId.make("thread-renamed"), + title: "Keep this name", + }); + yield* startTurn("thread-renamed", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 2)); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ title: "Keep this name" }); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-seeded"), + threadId: ThreadId.make("thread-seeded"), + projectId: asProjectId("project-1"), + title: "New thread", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-autotitle"), + threadId: ThreadId.make("thread-seeded"), + title: "hello there", + }); + yield* startTurn("thread-seeded", "hello there", "hello there"); + yield* Effect.promise(() => waitFor(() => harness.startSession.mock.calls.length === 3)); + expect(harness.startSession.mock.calls[2]?.[1]).not.toHaveProperty("title"); + }), + ); + effectIt.effect("projects inline context before sending the provider turn", () => Effect.gen(function* () { const harness = yield* Effect.promise(() => createHarness()); @@ -4339,6 +4427,77 @@ describe("ProviderCommandReactor", () => { expect(thread?.settledOverride).toBe("settled"); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); }), ); + + effectIt.effect("closes idle terminals when a thread without a session settles", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const terminalsClosed = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementation(() => + Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-without-session"), + threadId: ThreadId.make("thread-1"), + }); + yield* Deferred.await(terminalsClosed); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect( + "keeps terminals when the thread is un-settled before its settle event runs", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = ThreadId.make("thread-1"); + const firstCloseStarted = yield* Deferred.make(); + const releaseFirstClose = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementationOnce(() => + Deferred.succeed(firstCloseStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstClose)), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-first"), + threadId, + }); + // The reactor is busy with the first settle while the user changes their mind. + yield* Deferred.await(firstCloseStarted); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-first"), + threadId, + reason: "user", + }); + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-second"), + threadId, + }); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-second"), + threadId, + reason: "user", + }); + yield* Deferred.succeed(releaseFirstClose, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bdf4fe8e69d9..77022a8518d7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -65,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -222,6 +223,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const terminalManager = yield* TerminalManager.TerminalManager; /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -511,14 +513,14 @@ const make = Effect.gen(function* () { Effect.andThen( gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider command reactor failed to recreate worktree", { - threadId: thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), ), ); }); @@ -573,6 +575,9 @@ const make = Effect.gen(function* () { options?: { readonly modelSelection?: ModelSelection; readonly pendingTurnStart?: boolean; + // First-turn prompt seed. A manual title that still equals this seed was + // written by the client's auto-title, not a user rename. + readonly titleSeed?: string; }, ) { const thread = yield* resolveThreadShell(threadId); @@ -710,6 +715,15 @@ const make = Effect.gen(function* () { .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) .pipe(Effect.forkDetach) : Effect.void; + // OpenCode skips SessionPrompt.ensureTitle when session.create already has + // a title. Prompt seeds and "New thread" are not user titles, so omit them + // and let the provider generate one. A real rename is source "manual" and + // differs from the first-turn prompt seed (the web client writes that seed + // through thread.meta.update, which also marks the title manual). + const manualTitle = thread.titleState?.source === "manual" ? thread.title.trim() : ""; + const promptSeed = options?.titleSeed?.trim(); + const sessionTitle = + manualTitle.length > 0 && manualTitle !== promptSeed ? thread.title : undefined; const startProviderSession = (input?: { readonly resumeCursor?: unknown; @@ -721,7 +735,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), + ...(sessionTitle ? { title: sessionTitle } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, @@ -837,6 +851,7 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; + readonly titleSeed?: string; }) { const thread = yield* resolveThreadShell(input.threadId); if (!thread) { @@ -846,6 +861,7 @@ const make = Effect.gen(function* () { } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + ...(input.titleSeed !== undefined ? { titleSeed: input.titleSeed } : {}), pendingTurnStart: true, }); if (input.modelSelection !== undefined) { @@ -1154,15 +1170,14 @@ const make = Effect.gen(function* () { return; } const result = yield* regenerateThreadTitle(event, requestId).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("provider command reactor failed to regenerate thread title", { - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)), + ), ); if (result._tag === "Superseded") { return; @@ -1174,34 +1189,26 @@ const make = Effect.gen(function* () { ...(result.title !== undefined ? { title: result.title } : {}), }; yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning( - "provider command reactor retrying title regeneration completion", - { + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor retrying title regeneration completion", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }, - ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))); - }), + }).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))), + ), ); }, (effect, event) => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning( - "provider command reactor failed to complete title regeneration", - { + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to complete title regeneration", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }, - ); - }), + }), + ), ), ); const threadTitleRegenerationWorker = yield* makeDrainableWorker( @@ -1495,8 +1502,13 @@ const make = Effect.gen(function* () { : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, + // Later turns must not reuse the current title as titleSeed. Only the + // first prompt seed should suppress a not-yet-renamed session title. + ...(!hasOtherUserMessages && event.payload.titleSeed !== undefined + ? { titleSeed: event.payload.titleSeed } + : {}), }).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), ); @@ -1831,11 +1843,14 @@ const make = Effect.gen(function* () { return; case "thread.settled": { const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); - if ( - Option.isNone(thread) || - thread.value.session == null || - thread.value.session.status === "stopped" - ) { + // A thread re-engaged before this event ran keeps its shells and session. + if (Option.isNone(thread) || thread.value.settledOverride !== "settled") { + return; + } + // Idle shells close so they stop holding the worktree. A terminal that + // runs a command (a dev server, an editor) stays for the user to close. + yield* terminalManager.closeIdle({ threadId: event.payload.threadId }); + if (thread.value.session == null || thread.value.session.status === "stopped") { return; } yield* orchestrationEngine.dispatch({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index d61739f72c21..4c6e3703ba82 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3944,6 +3944,7 @@ describe("ProviderRuntimeIngestion", () => { turnId: asTurnId("turn-runtime-error-activity"), payload: { message: "runtime activity exploded", + code: "subscription_sharing_usage_limit_exceeded", }, }); @@ -3960,6 +3961,7 @@ describe("ProviderRuntimeIngestion", () => { expect(activity?.kind).toBe("runtime.error"); expect(activityPayload?.message).toBe("runtime activity exploded"); + expect(activityPayload?.code).toBe("subscription_sharing_usage_limit_exceeded"); }); it("keeps the session running when a runtime.warning arrives during an active turn", async () => { @@ -5174,4 +5176,59 @@ describe("splitBufferedAssistantText", () => { rest: "```\n- one\n- two\n", }); }); + + it("holds a heading until the block under it is done", () => { + expect(splitBufferedAssistantText("intro\n\n## Setup\n\nInstall it")).toEqual({ + ready: "intro\n\n", + rest: "## Setup\n\nInstall it", + }); + expect( + splitBufferedAssistantText("intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\nNext"), + ).toEqual({ + ready: "intro\n\n# Plan\n\n## Setup\n\nInstall it.\n\n", + rest: "Next", + }); + }); + + it("delivers the paragraph above a heading with no blank line between them", () => { + expect(splitBufferedAssistantText("para\n## Setup\n\nInstall")).toEqual({ + ready: "para\n", + rest: "## Setup\n\nInstall", + }); + // A bold line there continues the paragraph, so both stay buffered. + expect(splitBufferedAssistantText("para\n**Setup**\n\nInstall")).toEqual({ + ready: "", + rest: "para\n**Setup**\n\nInstall", + }); + }); + + it("holds a line of only bold text like a heading", () => { + expect(splitBufferedAssistantText("**Risk by area:**\n\n| a |\n|---|\n")).toEqual({ + ready: "", + rest: "**Risk by area:**\n\n| a |\n|---|\n", + }); + expect(splitBufferedAssistantText("**Use *npm* now**\n\nInstall it")).toEqual({ + ready: "", + rest: "**Use *npm* now**\n\nInstall it", + }); + expect(splitBufferedAssistantText("**Note:** read this.\n\nNext")).toEqual({ + ready: "**Note:** read this.\n\n", + rest: "Next", + }); + }); + + it("delivers a held heading with its first list item or its whole code block", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- tw")).toEqual({ + ready: "## Steps\n\n- one\n", + rest: "- tw", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n\nb\n")).toEqual({ + ready: "", + rest: "## Code\n\n```ts\na\n\nb\n", + }); + expect(splitBufferedAssistantText("## Code\n\n```ts\na\n```\nafter")).toEqual({ + ready: "## Code\n\n```ts\na\n```\n", + rest: "after", + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0db70e491235..2584a7578200 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -211,6 +211,12 @@ const BLANK_LINE_PATTERN = /^[ \t]*$/; // nested items count. The trailing space is required, so a partial `-` or // `1.` never matches before the model finishes the marker. const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; +// A section title: an ATX heading, or a line of only bold text, which models +// often use as a heading. +const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; +// An unindented ATX heading ends the paragraph or list above it, even with no +// blank line between them. A bold line would continue the paragraph instead. +const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; /** * Splits buffered assistant text at the last blank line, closing code fence, @@ -221,17 +227,26 @@ const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; * never leaks; a list item start is the one lookahead that may sit on the * partial line, since tight lists have no blank lines between items and would * otherwise land all at once. + * + * A section title holds the boundary until a content line follows it, so a + * title never lands alone and waits above a block that is still streaming. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: { marker: string; indent: number } | null = null; let boundary = -1; let lineStart = 0; + let titleAwaitingContent = false; for (;;) { const newline = text.indexOf("\n", lineStart); const line = text .slice(lineStart, newline === -1 ? text.length : newline) .replace(/[ \t\r]+$/, ""); - if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + if ( + openFence === null && + lineStart > 0 && + !titleAwaitingContent && + LIST_ITEM_START_PATTERN.test(line) + ) { boundary = lineStart; } if (newline === -1) { @@ -243,6 +258,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: const marker = fenceMatch[2]!; if (openFence === null) { openFence = { marker, indent }; + titleAwaitingContent = false; } else if ( marker[0] === openFence.marker[0] && marker.length >= openFence.marker.length && @@ -254,7 +270,14 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: boundary = newline + 1; } } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { - boundary = newline + 1; + if (!titleAwaitingContent) { + boundary = newline + 1; + } + } else if (openFence === null) { + if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) { + boundary = lineStart; + } + titleAwaitingContent = SECTION_TITLE_PATTERN.test(line); } lineStart = newline + 1; } @@ -548,6 +571,7 @@ export function runtimeEventToActivities( summary: "Runtime error", payload: { message: truncateDetail(event.payload.message), + ...(event.payload.code ? { code: event.payload.code } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -2648,17 +2672,16 @@ const make = Effect.gen(function* () { (source: string, event: { readonly eventId: string; readonly type: string }) => (effect: Effect.Effect) => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("provider runtime ingestion failed to process event", { - source, - eventId: event.eventId, - eventType: event.type, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider runtime ingestion failed to process event", { + source, + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker((input: RuntimeIngestionInput) => diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 14a92a5eaef5..092ca1b1d471 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -27,15 +27,14 @@ export const logCleanupCauseUnlessInterrupted = ({ readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; }): Effect.Effect => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logDebug(message, { - threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logDebug(message, { + threadId, + cause: Cause.pretty(cause), + }), + ), ); const make = Effect.gen(function* () { @@ -67,16 +66,15 @@ const make = Effect.gen(function* () { const processThreadDeletedSafely = (event: ThreadDeletedEvent) => processThreadDeleted(event).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("thread deletion reactor failed to process event", { - eventType: event.type, - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread deletion reactor failed to process event", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker(processThreadDeletedSafely); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index 5502376e691e..e6a18c2856a1 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -172,6 +172,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const snapshots = yield* Ref.make(options.snapshot); const events = yield* PubSub.unbounded(); const snapshotReads = yield* Queue.unbounded(); + const shellSnapshotReads = yield* Ref.make(0); const syncCommands = yield* Ref.make>([]); const linkCommands = yield* Ref.make>([]); const summaryCalls = yield* Ref.make>([]); @@ -209,8 +210,16 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ + listThreadsWithPullRequests: () => + Queue.offer(snapshotReads, undefined).pipe( + Effect.andThen(Ref.get(snapshots)), + Effect.map((snapshot) => snapshot.threads), + ), getShellSnapshot: () => - Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + Ref.update(shellSnapshotReads, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, undefined)), + Effect.andThen(Ref.get(snapshots)), + ), }), Layer.mock(PullRequestService)({ summary, @@ -235,6 +244,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: activation, snapshots, snapshotReads, + shellSnapshotReads, syncCommands, linkCommands, summaryCalls, @@ -511,6 +521,8 @@ describe("PullRequestSyncReactor", () => { ], ); assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + // Reads only linked threads, never the full shell snapshot of every thread. + assert.strictEqual(yield* Ref.get(fixture.shellSnapshotReads), 0); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 35819d9e7430..ffcc9fb3c26d 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -1,7 +1,6 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { CommandId, - type OrchestrationThreadShell, type PullRequestSummary, type ThreadPullRequestKey, type ThreadPullRequestLink, @@ -36,7 +35,7 @@ const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; type SnapshotFields = Omit; interface LinkEntry { - readonly thread: OrchestrationThreadShell; + readonly thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests; readonly link: ThreadPullRequestLink; } @@ -104,15 +103,15 @@ function stacksEqual( ); } -function isUnsettled(thread: OrchestrationThreadShell): boolean { +function isUnsettled(thread: ProjectionSnapshotQuery.ProjectionThreadPullRequests): boolean { return thread.settledOverride !== "settled" && thread.settledAt === null; } /** * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads - * the shell snapshot, groups visible links by pull request so the host is asked once per PR - * no matter how many threads share it, and writes back only what changed. Native stacks the - * host reports are auto-linked to the thread as `source: "stack"`. + * only the active threads that have links, groups visible links by pull request so the host + * is asked once per PR no matter how many threads share it, and writes back only what + * changed. Native stacks the host reports are auto-linked to the thread as `source: "stack"`. */ export class PullRequestSyncReactor extends Context.Service< PullRequestSyncReactor, @@ -153,14 +152,13 @@ export const make = Effect.gen(function* () { Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) { - const snapshot = yield* snapshots.getShellSnapshot(); + const threads = yield* snapshots.listThreadsWithPullRequests(); const now = yield* DateTime.now; const nowMs = DateTime.toEpochMillis(now); const nowIso = DateTime.formatIso(now); const groups = new Map>(); - for (const thread of snapshot.threads) { - if (thread.archivedAt !== null) continue; + for (const thread of threads) { for (const link of visibleThreadPullRequests(thread.pullRequests)) { const key = threadPullRequestKeyOf(link); const entries = groups.get(key) ?? []; @@ -259,12 +257,12 @@ export const make = Effect.gen(function* () { Effect.map((stack) => ({ stack: stack === null ? null : ({ kind: "native", ...stack } as const), })), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("pull request stack lookup failed", { - key, - }).pipe(Effect.as(null)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("pull request stack lookup failed", { + key, + }).pipe(Effect.as(null)), ), ) : null; @@ -303,7 +301,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 29468dc3f84e..f4fe2e0104f6 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,7 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadAutoSettleSetPayload as ContractsThreadAutoSettleSetPayloadSchema, ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, @@ -51,6 +52,7 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadAutoSettleSetPayload = ContractsThreadAutoSettleSetPayloadSchema; export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index eac3ede9c1ee..08da6e00940c 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -64,6 +64,12 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +/** The thread fields pull request sync reads, for a thread with at least one link. */ +export type ProjectionThreadPullRequests = Pick< + OrchestrationThreadShell, + "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" +>; + export interface ProjectionThreadDetailQuery { /** * Limit activities before SQLite returns and decodes their payloads. @@ -114,11 +120,15 @@ export interface ProjectionSnapshotQueryShape { * * Returns only projects and thread shell summaries so clients can bootstrap * lightweight navigation state without hydrating every thread body. + * + * `unsettledOnly` is for background sweeps, not clients. It skips settled + * threads and their sessions, PR links, and turns, and its `updatedAt` + * ignores those rows. It still resolves every project, which keeps + * repository identities cached for client connects. */ - readonly getShellSnapshot: () => Effect.Effect< - OrchestrationShellSnapshot, - ProjectionRepositoryError - >; + readonly getShellSnapshot: (options?: { + readonly unsettledOnly?: boolean; + }) => Effect.Effect; /** * Read archived thread shell summaries for the archive page. @@ -131,6 +141,16 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read active (not deleted, not archived) threads that have at least one pull + * request link, in shell snapshot order. Skips repository identity, so no + * legacy `linkedPullRequest` is derived. + */ + readonly listThreadsWithPullRequests: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** Durable worktree ownership retained after thread deletion, including across restarts. */ readonly getDeletedWorktreeThreads: () => Effect.Effect< ReadonlyArray<{ diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index d6572083dad2..2a08dd03bc54 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -21,18 +21,27 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { PullRequestService } from "../pullRequest/PullRequestService.ts"; import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { ServerActivation } from "../serverActivation.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; const NOW = "2026-09-01T12:00:00.000Z"; @@ -134,6 +143,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options readonly existingWorktrees?: ReadonlyArray; readonly project?: OrchestrationProjectShell; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; + /** Serve full sweep reads from this instead of `threads`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; }) { const activation = yield* Deferred.make(); const snapshots = yield* Ref.make({ @@ -142,7 +153,8 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options threads: options.threads, updatedAt: NOW, }); - const reads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const reads = yield* Queue.unbounded(); const events = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< @@ -152,8 +164,27 @@ const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options let uuid = 0; const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), + getShellSnapshot: (readOptions) => + (options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)).pipe( + Effect.tap(() => Queue.offer(reads, null)), + ), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(reads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), + ), }), Layer.mock(GitManager)({ branchPullRequest: (input, readOptions) => @@ -376,7 +407,7 @@ describe("ThreadPullRequestReactor", () => { : [checkpointEvent, sessionEvent]; for (const event of events) { yield* fixture.publish(event); - yield* Queue.take(fixture.reads); + expect(yield* Queue.take(fixture.reads)).toBe(current.id); yield* reactor.drain; } expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); @@ -388,6 +419,51 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("refreshes the project identity when a turn adds the remote", () => + Effect.scoped( + Effect.gen(function* () { + const current = thread("new-remote"); + const fixture = yield* makeHarness({ + threads: [current], + project: { ...project, repositoryIdentity: null }, + branchPullRequest: () => Effect.succeed(branchPullRequest()), + resolveRepositoryIdentity: (_cwd, options) => + Effect.succeed(options?.refresh ? project.repositoryIdentity : null), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + + yield* fixture.publish({ + type: "thread.turn-diff-completed", + sequence: 2, + eventId: EventId.make("checkpoint-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), + status: "ready", + files: [], + assistantMessageId: null, + completedAt: NOW, + }, + }); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => Effect.scoped( Effect.gen(function* () { @@ -516,6 +592,23 @@ describe("ThreadPullRequestReactor", () => { yield* Effect.gen(function* () { const reactor = yield* fixture.start(); expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + // A one-thread read cannot show that other pending threads are gone. + const gone = ThreadId.make("gone"); + yield* fixture.publish({ + type: "thread.unarchived", + sequence: 2, + eventId: EventId.make("gone-unarchived"), + aggregateKind: "thread", + aggregateId: gone, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: gone, updatedAt: NOW }, + }); + expect(yield* Queue.take(fixture.reads)).toBe(gone); + yield* reactor.drain; yield* Ref.set(online, true); yield* TestClock.adjust("1 minute"); yield* Queue.take(fixture.reads); @@ -564,6 +657,113 @@ describe("ThreadPullRequestReactor", () => { ), ); + it.effect("discovers the same PRs from the unsettled read as from the full read", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('dormant', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('open', 'project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open', NULL, ${NOW}, ${NOW}, NULL, NULL, NULL), + ('resumed', 'project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'resumed', NULL, ${NOW}, ${NOW}, NULL, 'active', NULL), + ('linked', 'project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'linked', '{"projectId":"project","repository":"owner/repository","number":3,"url":"https://github.com/owner/repository/pull/3"}', ${NOW}, ${NOW}, NULL, NULL, NULL), + ('settled', 'project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'settled', '{"projectId":"project","repository":"owner/repository","number":4,"url":"https://github.com/owner/repository/pull/4"}', ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('backfill', 'project', 'Backfill', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'backfill', NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('imported', 'dormant', 'Imported', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, ${NOW}, ${NOW}, NULL, 'settled', ${NOW}), + ('archived', 'project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'archived', NULL, ${NOW}, ${NOW}, ${NOW}, NULL, NULL)`; + const numbers = new Map([ + ["open", 1], + ["resumed", 2], + ["linked", 3], + ["settled", 4], + ["backfill", 5], + ["archived", 6], + ]); + + // Startup, then two periodic passes. The startup backfill lookup fails, + // so the first periodic pass retries it from the full read. + const discover = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const online = yield* Ref.make(false); + const reads: Array> = []; + const fixture = yield* makeHarness({ + threads: [], + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => reads.push(snapshot.threads.map(({ id }) => id).toSorted())), + ), + ), + branchPullRequest: ({ cwd, branch }) => + Effect.gen(function* () { + if (branch === "backfill" && !(yield* Ref.get(online))) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }); + } + const number = numbers.get(branch); + return number === undefined ? null : branchPullRequest(number); + }), + }); + return yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + const startupCalls = (yield* Ref.get(fixture.branchCalls)).length; + const startupCommands = (yield* Ref.get(fixture.commands)).length; + yield* Ref.set(online, true); + for (let pass = 0; pass < 2; pass++) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + return { + reads, + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .slice(startupCalls) + .map(({ branch }) => branch) + .toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .slice(startupCommands) + .map( + ({ threadId, branchPullRequest }) => `${threadId} ${branchPullRequest?.number}`, + ) + .toSorted(), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { reads: unsettledReads, ...unsettled } = yield* discover(query.getShellSnapshot); + const { reads: fullReads, ...full } = yield* discover(() => query.getShellSnapshot()); + expect(unsettled).toEqual(full); + expect(unsettled.commands).toEqual([ + "backfill 5", + "open 1", + "open 1", + "resumed 2", + "resumed 2", + ]); + // The last pass has no backfill left, so it reads no settled thread. + expect(fullReads.at(-1)).toContain("imported"); + expect(unsettledReads.at(-1)).toEqual(["linked", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { + resolve: () => Effect.succeed(project.repositoryIdentity), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 86026632a6af..66ff81d5a9e7 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -6,6 +6,7 @@ import { CommandId, type OrchestrationEvent, type OrchestrationProjectShell, + type OrchestrationShellSnapshot, type ThreadId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -16,11 +17,13 @@ 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 * as Option from "effect/Option"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; +import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import { forkParked } from "../serverActivation.ts"; @@ -69,6 +72,37 @@ export function pullRequestMatchesProject( ); } +/** + * Read the shell state for a discovery or settlement sweep. A sweep for one + * thread reads that thread and the projects it names, not every thread. A + * sweep over all threads reads only unsettled threads, since both sweeps skip + * settled ones. Discovery's backfill does its own full read. + */ +export const readSweepSnapshot = ( + snapshots: ProjectionSnapshotQuery.ProjectionSnapshotQueryShape, + threadId: ThreadId | null, +): Effect.Effect< + Pick, + ProjectionRepositoryError +> => + threadId === null + ? snapshots.getShellSnapshot({ unsettledOnly: true }) + : Effect.gen(function* () { + // Read the sequence first. The thread is then at least this new, so a + // command guarded by the sequence is rejected rather than missing a change. + const { snapshotSequence } = yield* snapshots.getSnapshotSequence(); + const thread = yield* snapshots.getThreadShellById(threadId); + if (Option.isNone(thread)) return { snapshotSequence, projects: [], threads: [] }; + // Settlement also checks the project a saved pull request names. + const reference = thread.value.linkedPullRequest ?? thread.value.branchPullRequest; + const projects = yield* snapshots.getProjectShells( + reference == null + ? [thread.value.projectId] + : [thread.value.projectId, reference.projectId], + ); + return { snapshotSequence, projects, threads: [thread.value] }; + }); + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -97,7 +131,11 @@ export const make = Effect.gen(function* () { const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( request: RefreshRequest, ) { - const snapshot = yield* snapshots.getShellSnapshot(); + // Backfill looks up settled threads, so its passes read every thread. + const snapshot = + request.threadId === null && (request.backfill || pendingBackfill.size > 0) + ? yield* snapshots.getShellSnapshot() + : yield* readSweepSnapshot(snapshots, request.threadId); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { @@ -109,14 +147,19 @@ export const make = Effect.gen(function* () { } } } - const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); - for (const threadId of pendingBackfill.keys()) { - if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + // A single-thread read only shows whether its own thread is gone. A thread + // with no branch has nothing to look up, and its entry would keep every + // periodic pass on the full read. + const branchThreadIds = new Set( + snapshot.threads.filter((thread) => thread.branch !== null).map((thread) => thread.id), + ); + const checkedIds = request.threadId === null ? pendingBackfill.keys() : [request.threadId]; + for (const threadId of checkedIds) { + if (!branchThreadIds.has(threadId)) pendingBackfill.delete(threadId); } const threads = snapshot.threads.filter( (thread) => thread.archivedAt === null && - (request.threadId === null || thread.id === request.threadId) && ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && @@ -131,8 +174,19 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const first = group[0]!; - const project = projects.get(first.projectId); - if (project === undefined) return finishBackfill(group); + const snapshotProject = projects.get(first.projectId); + if (snapshotProject === undefined) return finishBackfill(group); + // A finished turn may have added the remote this PR lives on. A failed + // refresh resolves to null, so keep the snapshot's identity then. + const project = request.refresh + ? { + ...snapshotProject, + repositoryIdentity: + (yield* repositoryIdentities.resolve(snapshotProject.workspaceRoot, { + refresh: true, + })) ?? snapshotProject.repositoryIdentity, + } + : snapshotProject; const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = @@ -207,16 +261,16 @@ export const make = Effect.gen(function* () { } return { thread, branchPullRequest, replacement }; }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request discovery failed", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe( - Effect.tap(() => Effect.sync(() => failBackfill([thread]))), - Effect.as(null), - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request discovery failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), + Effect.as(null), + ), ), ), ); @@ -273,25 +327,25 @@ export const make = Effect.gen(function* () { OrchestrationCommandInvariantError: () => Effect.sync(() => finishBackfill([thread])), }), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request update failed", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), ), ), { discard: true }, ); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread branch pull request lookup failed", { - threadIds: group.map((thread) => thread.id), - cause: Cause.pretty(cause), - }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread branch pull request lookup failed", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), { concurrency: 8, discard: true }, @@ -300,12 +354,12 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker((request: RefreshRequest) => synchronize(request).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request refresh failed", { - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request refresh failed", { + cause: Cause.pretty(cause), + }), ), ), ); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 252b99439400..8a1588b18752 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -171,6 +171,15 @@ describe("resolveAutoSettlementAt", () => { it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { expect(decide(makeThread({ settledOverride: "active" }))).toBe(false); + }); + + it("never settles a thread whose auto-settle is turned off, by inactivity or merge", () => { + const held = makeThread({ autoSettleDisabledAt: "2026-08-21T00:00:00.000Z" }); + expect(decide(held)).toBe(false); + expect( + decide(held, { state: "merged", mergedAt: "2026-08-21T00:00:00.000Z", closedAt: null }), + ).toBe(false); + expect(decide(makeThread({ autoSettleDisabledAt: null }))).toBe(true); expect(decide(makeThread({ snoozedUntil: "2026-08-29T00:00:00.000Z" }))).toBe(false); expect(decide(makeThread({ hasPendingApprovals: true }))).toBe(false); expect(decide(makeThread({ hasPendingUserInput: true }))).toBe(false); @@ -248,6 +257,21 @@ const terminalSnapshot = ( syncedAt: NOW, }); +describe("per-thread auto-settle opt out", () => { + it("blocks both inactivity and merge settlement while auto-settle is off", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe(true); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null })).toBe(true); + const held = { autoSettleDisabledAt: NOW }; + expect(decide(makeThread({ ...held, latestUserMessageAt: "2026-08-01T00:00:00.000Z" }))).toBe( + false, + ); + expect(decide(makeThread({ ...held, pullRequests: [merged] }), null, { days: null })).toBe( + false, + ); + }); +}); + describe("linked request settlement", () => { it.each(["closed", "merged"] as const)( "uses the latest actual %s transition despite later comments on another PR", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 92063745eff5..113d68204e59 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -117,6 +117,7 @@ export function resolveAutoSettlementAt(input: { /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate(thread: OrchestrationThreadShell, now: string): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; + if (thread.autoSettleDisabledAt != null) return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.session?.status === "starting" || thread.session?.status === "running") return false; if (thread.backgroundLiveness != null) return false; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 7719cd8833ed..70b79725c628 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -24,13 +24,17 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -38,11 +42,17 @@ import { import { ServerActivation } from "../serverActivation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Path from "effect/Path"; @@ -170,6 +180,8 @@ function makeBranchPullRequest( interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; + /** Serve full sweep reads from this instead of `snapshot`. */ + readonly getShellSnapshot?: ProjectionSnapshotQueryShape["getShellSnapshot"]; readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; @@ -183,7 +195,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const activation = yield* Deferred.make(); const snapshots = yield* Ref.make(options.snapshot); const snapshotReadCount = yield* Ref.make(0); - const snapshotReads = yield* Queue.unbounded(); + // Each shell read: a thread id for a one-thread read, null for a full read. + const snapshotReads = yield* Queue.unbounded(); const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); const settingsReads = yield* Queue.unbounded(); const settingsChanges = yield* PubSub.unbounded(); @@ -256,10 +269,27 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getShellSnapshot: () => - Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( - Effect.tap((count) => Queue.offer(snapshotReads, count)), - Effect.andThen(Ref.get(snapshots)), + getShellSnapshot: (readOptions) => + Ref.update(snapshotReadCount, (count) => count + 1).pipe( + Effect.andThen(Queue.offer(snapshotReads, null)), + Effect.andThen(options.getShellSnapshot?.(readOptions) ?? Ref.get(snapshots)), + ), + getSnapshotSequence: () => + Ref.get(snapshots).pipe(Effect.map(({ snapshotSequence }) => ({ snapshotSequence }))), + getThreadShellById: (threadId) => + Ref.get(snapshots).pipe( + Effect.map(({ threads }) => + Option.fromUndefinedOr( + threads.find((thread) => thread.id === threadId && thread.archivedAt === null), + ), + ), + Effect.tap(() => Queue.offer(snapshotReads, threadId)), + ), + getProjectShells: (projectIds) => + Ref.get(snapshots).pipe( + Effect.map(({ projects }) => + projects.filter((project) => projectIds?.includes(project.id) ?? true), + ), ), }), Layer.mock(GitManager)({ @@ -315,7 +345,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"], activation: Deferred.Deferred, - snapshotReads: Queue.Queue, + snapshotReads: Queue.Queue, ) { // Automatic sweeps are muted for the boot delay. Step past it before starting // so the periodic tick fired by `start()` is allowed to sweep straight away. @@ -448,7 +478,7 @@ describe("ThreadSettlementReactor", () => { updatedAt: NOW, }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), thread.id); yield* reactor.drain; } assert.deepStrictEqual( @@ -468,7 +498,7 @@ describe("ThreadSettlementReactor", () => { aggregateId: readySession.threadId, payload: { threadId: readySession.threadId, session: readySession }, }); - yield* Queue.take(fixture.snapshotReads); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), readySession.threadId); yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), @@ -480,6 +510,49 @@ describe("ThreadSettlementReactor", () => { }), ), ); + it.effect("skips the branch recheck when a terminal link would settle nothing", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const previous = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("resumed-manual", { + branch: "main", + linkedPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ], + [makeProject()], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")), + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { @@ -1421,7 +1494,7 @@ describe("ThreadSettlementReactor", () => { assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); yield* TestClock.adjust("1 minute"); - assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 1); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), null); yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map((command) => command.threadId), @@ -1451,7 +1524,7 @@ describe("ThreadSettlementReactor", () => { assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 1); yield* TestClock.adjust("1 minute"); - assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 2); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), null); yield* reactor.drain; assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2); }).pipe(Effect.provide(fixture.layer)); @@ -1533,7 +1606,7 @@ describe("ThreadSettlementReactor", () => { assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0); yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); - assert.strictEqual(yield* Queue.take(fixture.snapshotReads), 1); + assert.strictEqual(yield* Queue.take(fixture.snapshotReads), null); yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map((command) => command.threadId), @@ -1543,6 +1616,88 @@ describe("ThreadSettlementReactor", () => { }), ), ); + + it.effect("settles the same threads from the unsettled read as from the full read", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('settlement-project', 'Project', '/workspace/project', '[]', ${NOW}, ${NOW}), + ('linked-settlement-project', 'Linked', '/workspace/linked', '[]', ${NOW}, ${NOW}), + ('dormant-project', 'Dormant', '/workspace/dormant', '[]', ${NOW}, ${NOW})`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, branch_pull_request_json, latest_user_message_at, created_at, updated_at, archived_at, settled_override, settled_at) + VALUES + ('idle', 'settlement-project', 'Idle', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('merged', 'settlement-project', 'Merged', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'feature', + '{"projectId":"linked-settlement-project","repository":"owner/repository","number":42,"url":"https://example.test/owner/repository/pull/42"}', + '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('linked', 'settlement-project', 'Linked', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('open', 'settlement-project', 'Open', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'open-feature', NULL, '2026-08-27T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, NULL, NULL), + ('resumed', 'settlement-project', 'Resumed', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'active', NULL), + ('settled', 'dormant-project', 'Settled', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', 'done', NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, NULL, 'settled', '2026-08-21T00:00:00.000Z'), + ('archived', 'settlement-project', 'Archived', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', NULL, NULL, '2026-08-20T00:00:00.000Z', '2026-08-01T00:00:00.000Z', ${NOW}, ${NOW}, NULL, NULL)`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at, snapshot_json) + VALUES ('linked', 'example.test', 'owner/repository', 7, 'https://example.test/owner/repository/pull/7', 'manual', ${NOW}, + '{"state":"merged","title":"Review","headBranch":"linked","baseBranch":"main","isDraft":false,"updatedAt":"2026-08-28T12:00:00.000Z","syncedAt":"2026-08-28T12:00:00.000Z","mergedAt":"2026-08-28T12:00:00.000Z","closedAt":null}')`; + + const sweep = (read: ProjectionSnapshotQueryShape["getShellSnapshot"]) => + Effect.gen(function* () { + const readThreadIds: Array = []; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + getShellSnapshot: (options) => + read(options).pipe( + Effect.tap((snapshot) => + Effect.sync(() => readThreadIds.push(...snapshot.threads.map(({ id }) => id))), + ), + ), + branchPullRequest: ({ branch }) => + Effect.succeed(branch === "open-feature" ? makeBranchPullRequest("open") : null), + pullRequestSummary: (input) => + Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), + }); + return yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + return { + readThreadIds: readThreadIds.toSorted(), + commands: (yield* Ref.get(fixture.commands)) + .map(({ threadId, settledAt }) => `${threadId} ${settledAt}`) + .toSorted(), + branchCalls: (yield* Ref.get(fixture.branchCalls)) + .map(({ branch }) => branch) + .toSorted(), + summaryCalls: yield* Ref.get(fixture.summaryCalls), + }; + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.scoped); + + const { readThreadIds: unsettledReads, ...unsettled } = yield* sweep(query.getShellSnapshot); + const { readThreadIds: fullReads, ...full } = yield* sweep(() => query.getShellSnapshot()); + assert.deepStrictEqual(unsettled, full); + // A settle for inactivity, for a synced merged link, and for a saved + // branch PR whose project only that PR names. + assert.deepStrictEqual(unsettled.commands, [ + "idle 2026-08-20T00:00:00.000Z", + "linked 2026-08-27T00:00:00.000Z", + "merged 2026-08-27T00:00:00.000Z", + ]); + assert.deepStrictEqual(fullReads, [...unsettledReads, "settled"].toSorted()); + assert.deepStrictEqual(unsettledReads, ["idle", "linked", "merged", "open", "resumed"]); + }).pipe( + Effect.provide( + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver, { resolve: () => Effect.succeed(null) }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), + ); }); describe("storage cleanup", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index d8fa14488a38..ea36ed59107c 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -26,7 +26,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; -import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; +import { pullRequestMatchesProject, readSweepSnapshot } from "./ThreadPullRequestReactor.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt, @@ -149,20 +149,16 @@ export const make = Effect.gen(function* () { if (!autoSettlementConfigured(settings)) { return; } - const snapshot = yield* snapshots.getShellSnapshot(); + const snapshot = yield* readSweepSnapshot(snapshots, threadId ?? null); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); // A merge rechecks all candidates, including branches that discovery has // not linked yet. Those lookups can still have cached the PR as open. - const candidates = snapshot.threads.filter( - (thread) => - (threadId === undefined || thread.id === threadId) && - isAutoSettlementCandidate(thread, now), - ); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Return the thread when it still needs a pull request decision. A rejected // dispatch skips it for this snapshot instead of retrying through a lookup. - const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + const settleThread = Effect.fnUntraced( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { const settings = resolveProjectSettings( yield* settingsService.getSettings, @@ -191,13 +187,13 @@ export const make = Effect.gen(function* () { }, (effect, thread) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.as(null)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), ), ), ); @@ -263,9 +259,30 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); + const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* ( + group: ReadonlyArray<(typeof candidates)[number]>, + pullRequest: SettlementPullRequest, + ) { + const currentSettings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + return group.some((thread) => { + const { settings } = resolveProjectSettings(currentSettings, thread.projectId); + return ( + resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) !== null + ); + }); + }); + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( - thread: (typeof candidates)[number], + group: ReadonlyArray<(typeof candidates)[number]>, ) { + const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { const matchesMerge = @@ -290,10 +307,21 @@ export const make = Effect.gen(function* () { }, { recoverTransientFailure: false }, ); + const terminal = { + state: summary.state, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + } satisfies SettlementPullRequest; const cwd = lookupCwdByThreadId.get(thread.id); if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { // A reused branch can already have a new open PR while discovery // is replacing its old link. Do not let settlement win that race. + // Only pay for the uncached lookup when this sweep would otherwise + // settle: a terminal link that settles nothing (resumed thread, + // settle-on-merge off) would re-query the host every minute. A + // group that becomes eligible after this check waits for the next + // sweep rather than settling on the unverified link. + if (!(yield* wouldSettle(group, terminal))) return undefined; const current = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh: true }, @@ -307,11 +335,7 @@ export const make = Effect.gen(function* () { return current; } } - return { - state: summary.state, - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - } satisfies SettlementPullRequest; + return terminal; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); @@ -325,18 +349,19 @@ export const make = Effect.gen(function* () { groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequest = yield* pullRequestFor(group); + if (pullRequest === undefined) return; yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { discard: true, }); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadIds: group.map((thread) => thread.id), - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), ), ), { concurrency: SETTLEMENT_SWEEP_CONCURRENCY, discard: true }, @@ -348,12 +373,12 @@ export const make = Effect.gen(function* () { threadId?: ThreadId, ) => sweep(mergedPullRequest, threadId).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement sweep failed", { - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), ), ); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 873eab007bea..244a6a9e9cab 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -119,15 +119,13 @@ export function requireThreadArchived(input: { readonly threadId: ThreadId; }): Effect.Effect { return requireThread(input).pipe( - Effect.flatMap((thread) => - thread.archivedAt !== null - ? Effect.succeed(thread) - : Effect.fail( - invariantError( - input.command.type, - `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`, - ), - ), + Effect.filterOrFail( + (thread) => thread.archivedAt !== null, + () => + invariantError( + input.command.type, + `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`, + ), ), ); } @@ -138,15 +136,13 @@ export function requireThreadNotArchived(input: { readonly threadId: ThreadId; }): Effect.Effect { return requireThread(input).pipe( - Effect.flatMap((thread) => - thread.archivedAt === null - ? Effect.succeed(thread) - : Effect.fail( - invariantError( - input.command.type, - `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`, - ), - ), + Effect.filterOrFail( + (thread) => thread.archivedAt === null, + () => + invariantError( + input.command.type, + `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`, + ), ), ); } diff --git a/apps/server/src/orchestration/decider.autoSettleSet.test.ts b/apps/server/src/orchestration/decider.autoSettleSet.test.ts new file mode 100644 index 000000000000..99657069cc74 --- /dev/null +++ b/apps/server/src/orchestration/decider.autoSettleSet.test.ts @@ -0,0 +1,154 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const DISABLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly autoSettleDisabledAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + autoSettleDisabledAt: input.autoSettleDisabledAt ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const events = (event: Effect.Success>) => + Array.isArray(event) ? event : [event]; + +it.layer(NodeServices.layer)("thread.auto-settle.set decider", (it) => { + it.effect("turning auto-settle off stamps autoSettleDisabledAt and updatedAt together", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({}), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(event.payload.updatedAt); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("turning it off again keeps the original stamp and updatedAt", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-off-again"), + threadId: ThreadId.make("thread-1"), + enabled: false, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBe(DISABLED_AT); + expect(event.payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("turning auto-settle back on clears the stamp", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.auto-settle.set", + commandId: CommandId.make("cmd-on"), + threadId: ThreadId.make("thread-1"), + enabled: true, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.auto-settle-set"); + if (event?.type === "thread.auto-settle-set") { + expect(event.payload.autoSettleDisabledAt).toBeNull(); + expect(event.payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("automatic settlement is rejected while auto-settle is off", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decideOrchestrationCommand({ + command: { + type: "thread.auto-settle", + commandId: CommandId.make("cmd-auto"), + threadId: ThreadId.make("thread-1"), + snapshotSequence: 0, + settledAt: NOW, + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(result._tag).toBe("Failure"); + }), + ); + + it.effect("a manual settle still works while auto-settle is off", () => + Effect.gen(function* () { + const [event] = events( + yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-manual"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ autoSettleDisabledAt: DISABLED_AT }), + }), + ); + expect(event?.type).toBe("thread.settled"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts index b79ca01a8dea..7a961af12d5b 100644 --- a/apps/server/src/orchestration/decider.pullRequests.test.ts +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -295,7 +295,7 @@ it.layer(NodeServices.layer)("pull request link decider", (it) => { for (const planned of events) { const event = { ...planned, sequence: model.snapshotSequence + 1 }; const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event); - const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded); + const decoded = yield* Schema.decodeEffect(OrchestrationEvent)(encoded); // Older detail-event unions must never receive the new PR discriminants. expect(isThreadDetailEvent(decoded)).toBe(false); model = yield* projectEvent(model, decoded); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 689910a0d6b7..dcb715eaf3ab 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -484,13 +484,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} changed before automatic settlement`, - }), - ); + if ( + command.type === "thread.auto-settle" && + (thread.settledOverride !== null || thread.autoSettleDisabledAt != null) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} changed before automatic settlement`, + }); } // The server owns settle eligibility. A stale command must not settle // a thread whose session is coming alive or working. @@ -642,36 +643,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // structurally just a string): NaN fails every comparison, and an // unparseable snoozedUntil must never persist. if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }); } // Blocked-on-you work must not be snoozed away: a pending approval or // user-input request is the agent waiting on the user, and hiding it // defeats the request. (A running session IS snoozable — snooze only // affects visibility, never the agent.) if (openRequests(thread).size > 0) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }); } // A queued turn start — a user message no turn has adopted yet — is // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. if (hasQueuedTurnStartForThread(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }); } // Re-snoozing an already-snoozed thread to the SAME wake time is a // duplicate (double-click, raced clients): re-emit with the original @@ -833,12 +828,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // (rather than silently pinning) keeps a raced reorder-after-unpin // from resurrecting a pin the user just cleared. if (thread.pinnedAt == null) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} is not pinned and cannot be reordered`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }); } // Idempotent by re-emission (see thread.settle): a duplicate drop on // the same slot keeps the existing updatedAt so it projects as a no-op. @@ -860,6 +853,37 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.auto-settle.set": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.unpin): setting the current + // state again keeps the existing timestamps so duplicates do not churn + // ordering. The flag is independent of the settled lifecycle: it only + // gates the automatic paths, so it never blocks a manual settle. + const currentlyDisabledAt = thread.autoSettleDisabledAt ?? null; + const unchanged = command.enabled + ? currentlyDisabledAt === null + : currentlyDisabledAt !== null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.auto-settle-set", + payload: { + threadId: command.threadId, + autoSettleDisabledAt: command.enabled ? null : (currentlyDisabledAt ?? occurredAt), + updatedAt: unchanged ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.active.reorder": { const thread = yield* requireThreadNotArchived({ readModel, @@ -919,12 +943,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (command.linkedPullRequest != null) { const { linkedPullRequest: linked, ...metadata } = command; const project = readModel.projects.find((project) => project.id === thread.projectId); - let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; - try { - host = new URL(linked.url).hostname; - } catch { - // Historical clients can send links without a parseable URL. - } + // Historical clients can send links without a parseable URL. + const host = URL.canParse(linked.url) + ? new URL(linked.url).hostname + : (project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"); const hasMetadata = Object.entries(metadata).some( ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, ); @@ -1842,12 +1864,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" sessionComingAlive || hasQueuedTurnStartForThread(thread, command.createdAt) ) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, + }); } } return { diff --git a/apps/server/src/orchestration/projector.autoSettleSet.test.ts b/apps/server/src/orchestration/projector.autoSettleSet.test.ts new file mode 100644 index 000000000000..cc12f6905910 --- /dev/null +++ b/apps/server/src/orchestration/projector.autoSettleSet.test.ts @@ -0,0 +1,105 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects auto-settle opt-out and survives a manual settle", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const later = "2026-01-02T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.autoSettleDisabledAt ?? null).toBeNull(); + + const disabled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.auto-settle-set", + payload: { threadId: ThreadId.make("thread-1"), autoSettleDisabledAt: now, updatedAt: now }, + }), + ); + expect(disabled.threads[0]?.autoSettleDisabledAt).toBe(now); + + // The flag is independent of the settled lifecycle: settling by hand and + // un-settling later must not clear it. + const settled = yield* projectEvent( + disabled, + makeEvent({ + sequence: 3, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: later, updatedAt: later }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const unsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: later }, + }), + ); + expect(unsettled.threads[0]?.autoSettleDisabledAt).toBe(now); + + const enabled = yield* projectEvent( + unsettled, + makeEvent({ + sequence: 5, + type: "thread.auto-settle-set", + payload: { + threadId: ThreadId.make("thread-1"), + autoSettleDisabledAt: null, + updatedAt: later, + }, + }), + ); + expect(enabled.threads[0]?.autoSettleDisabledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c4e1996f1ddd..516b85fbdae8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -93,6 +93,7 @@ describe("orchestration projector", () => { updatedAt: now, archivedAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, settledOverride: null, settledAt: null, unsettledAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 53013770b15b..dd418b95b9bc 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -42,6 +42,7 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadAutoSettleSetPayload, ThreadPullRequestLinkedPayload, ThreadPullRequestSyncedPayload, ThreadPullRequestUnlinkedPayload, @@ -116,12 +117,26 @@ function settledTurnStateForSessionStatus( } } +// Runs for every thread event (including streaming deltas) against every +// thread the server has ever seen, so copy the array rather than map it. function updateThread( threads: ReadonlyArray, threadId: ThreadId, patch: ThreadPatch, -): OrchestrationThread[] { - return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +): ReadonlyArray { + const index = threads.findIndex((thread) => thread.id === threadId); + return index === -1 ? threads : patchThreadAt(threads, index, patch); +} + +/** For callers that already located the thread and must not scan again. */ +function patchThreadAt( + threads: ReadonlyArray, + index: number, + patch: ThreadPatch, +): ReadonlyArray { + const next = threads.slice(); + next[index] = { ...threads[index]!, ...patch }; + return next; } /** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ @@ -440,6 +455,7 @@ export function projectEvent( settledAt: null, unsettledAt: null, activeOrderKey: null, + autoSettleDisabledAt: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -580,6 +596,17 @@ export function projectEvent( })), ); + case "thread.auto-settle-set": + return decodeForEvent(ThreadAutoSettleSetPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + autoSettleDisabledAt: payload.autoSettleDisabledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.pin-reordered": return decodeForEvent(ThreadPinReorderedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ @@ -754,7 +781,8 @@ export function projectEvent( event.type, "payload", ); - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -802,7 +830,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { messages: cappedMessages, updatedAt: event.occurredAt, }), @@ -1042,7 +1070,8 @@ export function projectEvent( "payload", ).pipe( Effect.map((payload) => { - const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + const threadIndex = nextBase.threads.findIndex((entry) => entry.id === payload.threadId); + const thread = nextBase.threads[threadIndex]; if (!thread) { return nextBase; } @@ -1056,7 +1085,7 @@ export function projectEvent( return { ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { + threads: patchThreadAt(nextBase.threads, threadIndex, { activities, updatedAt: event.occurredAt, }), diff --git a/apps/server/src/orchestration/workflowScriptQuery.ts b/apps/server/src/orchestration/workflowScriptQuery.ts index 06bbd35ccf62..184abc6147ce 100644 --- a/apps/server/src/orchestration/workflowScriptQuery.ts +++ b/apps/server/src/orchestration/workflowScriptQuery.ts @@ -32,9 +32,10 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( const requested = input.scriptPath; if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "invalid-path", + scriptPath: requested, + }); } const root = yield* Effect.tryPromise({ @@ -60,14 +61,16 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( }); if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "outside-root", + scriptPath: resolved, + }); } if (NodePath.extname(resolved) !== ".js") { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "not-js", + scriptPath: resolved, + }); } // TOCTOU-safe read (review finding): open FIRST, then verify what was diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index aae55dd2fe06..7ad1d95ed7bc 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -268,7 +268,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthPairingLinkDbRow(row).pipe( Effect.mapError((cause) => @@ -278,7 +278,7 @@ export const make = Effect.gen(function* () { { pairingLinkId: row.id }, ), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthPairingLinkDbRow(row).pipe( Effect.mapError((cause) => @@ -339,7 +339,7 @@ export const make = Effect.gen(function* () { { pairingLinkId: row.id }, ), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 836b1f8f2e30..bbc8fc75685a 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -422,7 +422,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthSessionDbRow(row).pipe( Effect.mapError((cause) => diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index af36578f286e..595bc0b5594c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at, pin_order_key, active_order_key, + auto_settle_disabled_at, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -86,6 +87,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pinnedAt}, ${row.pinOrderKey ?? null}, ${row.activeOrderKey ?? null}, + ${row.autoSettleDisabledAt ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -118,6 +120,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, active_order_key = excluded.active_order_key, + auto_settle_disabled_at = excluded.auto_settle_disabled_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -157,6 +160,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", active_order_key AS "activeOrderKey", + auto_settle_disabled_at AS "autoSettleDisabledAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30a..94443c867f04 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -317,9 +317,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), + onNone: () => Effect.succeedNone, + onSome: (row) => Effect.succeedSome(row as Schema.Schema.Type), }), ), ); diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts index 0b64e4f7fdcb..5bcbd35e918c 100644 --- a/apps/server/src/persistence/Layers/Sqlite.test.ts +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -10,7 +10,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { + SqlitePersistenceMemory, + WAL_SIZE_LIMIT_BYTES, + makeSqlitePersistenceLive, +} from "./Sqlite.ts"; const lockHolderSource = ` const { DatabaseSync } = require("node:sqlite"); @@ -57,6 +61,32 @@ it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", ( ); }); +it.effect("shrinks the WAL file back to the size limit after a large write", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-wal-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + const walFileSize = () => NodeFS.statSync(`${dbPath}-wal`).size; + // About 25% more 4 KB rows than the limit holds, in one transaction. + const rowCount = Math.ceil((WAL_SIZE_LIMIT_BYTES * 1.25) / 4000); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE wal_probe(payload BLOB)`; + yield* sql` + WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ${rowCount}) + INSERT INTO wal_probe(payload) SELECT randomblob(4000) FROM n + `; + assert.isAbove(walFileSize(), WAL_SIZE_LIMIT_BYTES); + + // The auto-checkpoint after the large commit copied every frame into the + // database, so the next commit restarts the WAL and cuts the file back. + yield* sql`INSERT INTO wal_probe(payload) VALUES (x'00')`; + assert.isAtMost(walFileSize(), WAL_SIZE_LIMIT_BYTES); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + it.effect("applies busy_timeout in the shared persistence setup", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 750edaab8986..19809b3773b2 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -8,6 +8,9 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; +// Size the -wal file is cut back to on the first commit after a WAL reset. +export const WAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024; + const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -18,6 +21,9 @@ const setup = Layer.effectDiscard( // T3 Turbo: the standard WAL companion. An app crash still loses nothing; only a power loss // or hard reset can drop the most recent commits. yield* sql`PRAGMA synchronous = NORMAL;`; + // PASSIVE checkpoints never shrink the -wal file, so it otherwise keeps its + // largest size until the last connection closes. + yield* sql.unsafe(`PRAGMA journal_size_limit = ${WAL_SIZE_LIMIT_BYTES};`); yield* runMigrations(); }), ); @@ -35,7 +41,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), - "service.name": "t3-server", + "service.name": "t3code-server", }, }), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 18aae09febf8..c837ae3f4c50 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -65,6 +65,7 @@ import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; import Migration0051 from "./Migrations/051_ProjectionThreadMessageContext.ts"; import Migration0052 from "./Migrations/052_ProjectionThreadTitleState.ts"; import Migration0053 from "./Migrations/053_PullRequestFilesViewed.ts"; +import Migration0054 from "./Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts"; /** * Migration loader with all migrations defined inline. @@ -130,6 +131,7 @@ const migrationEntries = [ [51, "ProjectionThreadMessageContext", Migration0051], [52, "ProjectionThreadTitleState", Migration0052], [53, "PullRequestFilesViewed", Migration0053], + [54, "ProjectionThreadsAutoSettleDisabledAt", Migration0054], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts new file mode 100644 index 000000000000..df6403316e5b --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateAutoSettleDisabledAt from "./054_ProjectionThreadsAutoSettleDisabledAt.ts"; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))( + "054_ProjectionThreadsAutoSettleDisabledAt", + (it) => { + it.effect("adds the column with auto-settle left on for existing threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 53 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 54 }); + const migrated = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ autoSettleDisabledAt: null }]); + // Re-running against a database that already has the column keeps its value. + yield* sql`UPDATE projection_threads SET auto_settle_disabled_at = ${now} WHERE thread_id = 'thread-1'`; + yield* migrateAutoSettleDisabledAt; + const rows = yield* sql<{ readonly autoSettleDisabledAt: string | null }>` + SELECT auto_settle_disabled_at AS "autoSettleDisabledAt" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ autoSettleDisabledAt: now }]); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts new file mode 100644 index 000000000000..f91f6d8abdfa --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_ProjectionThreadsAutoSettleDisabledAt.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "auto_settle_disabled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN auto_settle_disabled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2673512edf10..1ec7adcd623b 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -102,11 +102,14 @@ export class ProviderSessionRuntimeRepository extends Context.Service< >; /** - * List all provider runtime rows. + * List provider runtime rows. * - * Returned in ascending last-seen order. + * Returned in ascending last-seen order. `excludeStopped` filters stopped + * rows in SQL. Long-lived installs keep thousands for their resume cursors. */ - readonly list: () => Effect.Effect< + readonly list: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionRuntimeRepositoryError >; @@ -336,9 +339,9 @@ export const make = Effect.gen(function* () { }); const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: Schema.Struct({ excludeStopped: Schema.Boolean }), Result: ProviderSessionRuntimeRawDbRowSchema, - execute: () => + execute: ({ excludeStopped }) => sql` SELECT thread_id AS "threadId", @@ -351,6 +354,7 @@ export const make = Effect.gen(function* () { resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime + ${excludeStopped ? sql`WHERE status != 'stopped'` : sql``} ORDER BY last_seen_at ASC, thread_id ASC `, }); @@ -398,7 +402,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeRuntimeRow(row).pipe( Effect.mapError((cause) => @@ -408,14 +412,14 @@ export const make = Effect.gen(function* () { { threadId: input.threadId }, ), ), - Effect.map((runtime) => Option.some(runtime)), + Effect.asSome, ), }), ), ); - const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => - listRuntimeRows(undefined).pipe( + const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) => + listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.list:query", @@ -428,7 +432,7 @@ export const make = Effect.gen(function* () { // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((cause) => Effect.logWarning("provider.session.runtime.row-skipped", { threadId: row.threadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 895fe596db24..abd964382a09 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({ pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + autoSettleDisabledAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 7fa15defeca9..d790002f95f1 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -18,6 +18,7 @@ import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { expect } from "vite-plus/test"; import { FetchHttpClient } from "effect/unstable/http"; @@ -441,6 +442,26 @@ effectIt.effect("stops probing a subscriber's configured paths after its scope c }).pipe(Effect.scoped, Effect.provide(layer)); }); +effectIt.effect("writes no poll span while no client retains the scanner", () => { + let pollSpans = 0; + const tracer = Tracer.make({ + span: (options) => { + if (options.name === "PortDiscovery.pollTick") pollSpans += 1; + return new Tracer.NativeSpan(options); + }, + }); + const layer = makeProbeFailureLayer(processProbeFailure); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* TestClock.adjust(Duration.seconds(15)); + expect(pollSpans).toBe(0); + + yield* scanner.retain; + expect(pollSpans).toBe(1); + }).pipe(Effect.scoped, Effect.provide(layer), Effect.withTracer(tracer)); +}); + effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { const requests: string[] = []; const fetchFn = ((input: Parameters[0]) => { diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index f4d73d62320d..9eee1a3e215e 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -550,7 +550,6 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const pollTick = Effect.fn("PortDiscovery.pollTick")( function* () { - if ((yield* Ref.get(stateRef)).retainCount <= 0) return; const configuredUrls = [ ...new Set( [...(yield* Ref.get(stateRef)).listeners.values()].flatMap( @@ -579,9 +578,12 @@ export const make = Effect.gen(function* PortDiscoveryMake() { ), ); - // Single layer-scoped polling fiber. Ticks are no-ops when no client is - // currently retained, so the cost is one Ref.get every POLL_INTERVAL. - yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + // Single layer-scoped polling fiber. Ticks skip the scan and its span when no + // client is currently retained, so the cost is one Ref.get every POLL_INTERVAL. + const pollIfRetained = Ref.get(stateRef).pipe( + Effect.flatMap((state) => (state.retainCount > 0 ? pollTick() : Effect.void)), + ); + yield* Effect.forkScoped(pollIfRetained.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = yield* Ref.modify(stateRef, (state) => [ diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 965860e714b1..91b0912b3ef2 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -974,6 +974,18 @@ it.effect("discovers editors through the service API", () => ); for (const { platform, installPath, editor, args } 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", @@ -1079,6 +1091,45 @@ for (const { platform, installPath, editor, args } of [ ); } +// `agy` is the standalone Antigravity CLI, which installs to ~/.local/bin on +// macOS and Linux and to its own bin folder on Windows. It is not the IDE. +for (const { platform, installPath, onPath } of [ + { platform: "darwin", installPath: ".local/bin/agy", onPath: true }, + { platform: "linux", installPath: ".local/bin/agy", onPath: false }, + { platform: "win32", installPath: "agy/bin/agy.cmd", onPath: true }, +] as const) { + it.effect.skipIf(windowsHost && platform !== "win32")( + `does not report the agy CLI as the Antigravity IDE on ${platform}`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-cli-" }); + const executable = path.join(home, installPath); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + const editors = yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + return yield* launcher.resolveAvailableEditors(); + }).pipe( + Effect.provide( + testLayer({ + platform, + env: { + HOME: home, + LOCALAPPDATA: home, + PATH: onPath ? path.dirname(executable) : path.join(home, "empty"), + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + ); + assert.notInclude(editors, "antigravity"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launchers first", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index d470bbfd5751..119ba883e0ca 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -22,7 +22,11 @@ import { } from "@t3tools/contracts"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import { + isCommandAvailable, + resolveSpawnCommand, + withPathDirectoryListings, +} from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -448,7 +452,7 @@ const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")( const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; - return yield* buildAvailableEditors(platform, env); + return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings); }); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..d7f3799e103b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -412,3 +412,11 @@ describe("isWindowsCommandNotFound", () => { }), ); }); + +describe("commandName", () => { + it("drops the directory from POSIX and Windows paths", () => { + expect(ProcessRunner.commandName("/Users/me/.local/bin/claude")).toBe("claude"); + expect(ProcessRunner.commandName("C:\\Program Files\\nodejs\\npx.cmd")).toBe("npx.cmd"); + expect(ProcessRunner.commandName("git")).toBe("git"); + }); +}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 0a9bb9b04a43..049125de3fbf 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -171,7 +171,8 @@ export const isWindowsCommandNotFound = Effect.fn("processRunner.isWindowsComman }, ); -const collectText = Effect.fn("processRunner.collectText")(function* (input: { +// Untraced: no attributes, and its time is the runProcessCore span. Errors fail that span. +const collectText = Effect.fnUntraced(function* (input: { readonly command: string; readonly args: ReadonlyArray; readonly cwd?: string | undefined; @@ -285,10 +286,14 @@ function finalizeRunProcess( ); } +/** The executable name without its directory, recorded as `process.command` on process spans. */ +export const commandName = (command: string) => command.replace(/^.*[\\/]/, ""); + const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], input: ProcessRunInput, ): Effect.fn.Return { + yield* Effect.annotateCurrentSpan("process.command", commandName(input.command)); const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4eb03a5cc036..2438edca8b1b 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr import { ServerSettingsService } from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; @@ -232,7 +233,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: (binding) => Effect.sync(() => void bindings.push(binding)), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); @@ -456,7 +457,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not replace an active binding"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, - getBinding: () => Effect.succeed(Option.some(runningBinding)), + getBinding: () => Effect.succeedSome(runningBinding), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); @@ -511,7 +512,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.die("unused"), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); @@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(Layer.mock(GitWorkflowService)({})), Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })), Layer.provide(ServerSettingsService.layerTest()), ); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 5ebb41a1bb54..bf9eb702ebe5 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -87,6 +87,7 @@ function hasImportBlockingActivity( thread.snoozedAt != null || thread.pinnedAt != null || thread.pinOrderKey != null || + thread.autoSettleDisabledAt != null || thread.titleRegeneration != null || thread.linkedPullRequest != null || thread.unsettledAt != null || diff --git a/apps/server/src/project/AgentSessionJson.test.ts b/apps/server/src/project/AgentSessionJson.test.ts new file mode 100644 index 000000000000..515aa50b1b97 --- /dev/null +++ b/apps/server/src/project/AgentSessionJson.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { createTranscriptJsonReader, TranscriptJsonLimitError } from "./AgentSessionJson.ts"; + +function read( + json: string, + size: number, + select: (path: ReadonlyArray) => boolean = () => true, +) { + const reader = createTranscriptJsonReader(() => {}, select); + for (let offset = 0; offset < json.length; offset += size) + reader.write(json.slice(offset, offset + size)); + return reader.finish(); +} + +describe("transcript JSON projection", () => { + it.each([1, 2, 7, 64, 1024])("matches JSON.parse across %i-character boundaries", (size) => { + for (const text of [ + '{"a":1,"a":2,"b":"before","b":"after"}', + '{"a":{"x":1},"a":{"y":2},"b":[],"c":{}}', + '{"a":[null,true,false,1,-2.3e4,"😀\\u0061\\\\\\\"",{},[],[1,2]]}', + '{"a":"s","a":null,"b":null,"b":"s","c":0,"c":false}', + '{"__proto__":{"polluted":true},"constructor":1,"__proto__":2}', + ]) + expect(read(text, size)).toEqual(JSON.parse(text)); + }); + + it("projects siblings and array elements without merging repeated parent objects", () => { + const text = + '{"message":{"usage":{"input":100},"content":"large"},"message":{"usage":{"output":5},"content":[1,2]},"rows":[{"keep":1,"drop":2},{"keep":3}],"drop":{"keep":4}}'; + const projected = read(text, 1, (path) => { + if (path[0] === "drop") return false; + return !path.includes("content") && !path.includes("drop"); + }); + expect(projected).toEqual({ + message: { usage: { output: 5 } }, + rows: [{ keep: 1 }, { keep: 3 }], + }); + }); + + it.each(['{"a":', '{"a":1} trailing', '{"a":1}{"a":2}', '{"a":"bad\\x"}', '{"a":[1,]}'])( + "rejects malformed input %s", + (text) => { + expect(read(text, 1)).toBeUndefined(); + }, + ); + + it("retains the import allocation and depth limits", () => { + const limited = createTranscriptJsonReader( + () => { + throw new TranscriptJsonLimitError("budget"); + }, + () => true, + ); + expect(() => limited.write('{"a":1}')).toThrow(TranscriptJsonLimitError); + expect(() => read("[".repeat(129) + "0" + "]".repeat(129), 10)).toThrow( + TranscriptJsonLimitError, + ); + }); +}); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index d31c47833d70..4deba0a67794 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -1,7 +1,6 @@ import * as SchemaAST from "effect/SchemaAST"; -import { isMany, none, type Many } from "stream-chain/defs.js"; +import { none, type Many } from "stream-chain/defs.js"; import { Assembler } from "stream-json/core/assembler.js"; -import { filter } from "stream-json/core/filters/filter.js"; import * as StreamJson from "stream-json/core/parser.js"; import type { ParserOptions, Token } from "stream-json/core/parser.js"; @@ -55,6 +54,7 @@ export class TranscriptJsonLimitError extends Error {} export function createTranscriptJsonReader( reserve: (bytes: number) => void, 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 @@ -65,9 +65,6 @@ export function createTranscriptJsonReader( ) => (input: string | typeof none) => Many | typeof none; }; const tokenize = jsonParser({ packValues: false }); - const select = filter({ filter: selectPath, streamKeys: false }) as ( - input: Token | typeof none, - ) => Token | Many | typeof none; const assembler = new Assembler(); let key: string | null = null; let value = ""; @@ -100,13 +97,62 @@ 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. + const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; + let selectedValue = false; + const startValue = () => { + const parent = stack.at(-1); + const path = parent?.selected ? [...parent.path, parent.key] : []; + const selected = (parent?.selected ?? true) && selectPath(path); + if (selected && typeof parent?.key === "string") { + assemble({ name: "keyValue", value: parent.key }); + } + return { path, selected }; + }; + const endValue = () => { + const parent = stack.at(-1); + if (parent && typeof parent.key === "number") parent.key++; + }; const selectToken = (token: Token | typeof none) => { - const selected = select(token); - if (selected === none) return; - if (isMany(selected)) { - for (const item of selected.values) assemble(item); - } else { - assemble(selected); + if (token === none) return; + switch (token.name) { + case "keyValue": { + const parent = stack.at(-1); + if (parent) parent.key = token.value; + return; + } + case "startObject": + case "startArray": { + const frame = startValue(); + stack.push({ ...frame, key: token.name === "startArray" ? 0 : null }); + if (frame.selected) assemble(token); + return; + } + case "endObject": + case "endArray": + if (stack.pop()?.selected) assemble(token); + endValue(); + return; + case "startString": + case "startNumber": + selectedValue = startValue().selected; + if (selectedValue) assemble(token); + return; + case "endString": + case "endNumber": + if (selectedValue) assemble(token); + endValue(); + return; + case "nullValue": + case "trueValue": + case "falseValue": + if (startValue().selected) assemble(token); + endValue(); + return; + default: + if (selectedValue) assemble(token); } }; const consume = (input: string | typeof none) => { @@ -116,8 +162,8 @@ export function createTranscriptJsonReader( if (tokens === none) return; for (const token of tokens.values) { if (token.name === "startObject" || token.name === "startArray") { - if (++depth > 128) - throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels"); + if (++depth > (options?.maxDepth ?? 128)) + throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds the depth limit"); } else if (token.name === "endObject" || token.name === "endArray") { if (--depth === 0) complete = true; } diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 792ef92310ff..2dde0643ed60 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -48,6 +48,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< updatedAt: "2026-01-01T00:00:00.000Z", }), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..8a8f3b310e03 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -662,7 +662,7 @@ export const make = Effect.gen(function* () { fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const statOption = (target: string) => - fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); /** Match directory aliases without assuming the host volume is case-insensitive. */ const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4fd36cb67b94..90cbcb574e81 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -143,7 +143,7 @@ const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), @@ -175,7 +175,7 @@ export const make = Effect.gen(function* () { relativePath, }) ).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ WorkspacePathOutsideRootError: () => Effect.succeed( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index dd341a7f7859..69c553a4deaf 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -34,6 +34,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), @@ -58,7 +59,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); type TerminalOverrides = Pick & - Partial>; + Partial>; const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { @@ -67,6 +68,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) => clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + closeIdle: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), ...overrides, @@ -262,6 +264,7 @@ describe("ProjectSetupScriptRunner", () => { listener = null; }); }); + const closeIdle = vi.fn(() => Effect.void); const project = makeProject([ { id: "setup", @@ -329,14 +332,82 @@ describe("ProjectSetupScriptRunner", () => { ]); // The subscription is torn down once the sentinel arrives. expect(listener).toBeNull(); + // A failed run keeps its shell open for a look. + expect(closeIdle).not.toHaveBeenCalled(); }).pipe( - Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), ); }, ); + it.effect("closes the idle setup shell after a clean exit", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + let written = ""; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void (written = input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const closeIdle = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + if (result.status !== "started" || !result.completion) { + return yield* Effect.die("expected an observed setup run"); + } + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + yield* listener!({ + threadId: "thread-1", + terminalId: "setup-setup", + type: "output", + data: `${sentinel}0\r\n`, + }); + + expect((yield* result.completion).exitCode).toBe(0); + expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" }); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }); + it.effect("unsubscribes from terminal output when the command cannot be written", () => { const open = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 16cbfaa59496..74bc41e5e8cd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted { * Resolves when the script's shell prints the completion sentinel. The * exit code is null when the terminal exited or was closed before the * sentinel arrived. Only present when `observeCompletion` was requested. + * An exit code of 0 closes the setup shell if it has nothing left running. */ readonly completion?: Effect.Effect; } @@ -412,6 +413,16 @@ export const make = Effect.gen(function* () { Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); + // A clean run leaves only an idle prompt behind; its output stays in the + // terminal history. A failed run keeps its shell open for a look. + const completion = observed?.completion.pipe( + Effect.tap(({ exitCode }) => + exitCode === 0 + ? terminalManager.closeIdle({ threadId: input.threadId, terminalId }) + : Effect.void, + ), + ); + return { status: "started", scriptId: script.id, @@ -420,7 +431,7 @@ export const make = Effect.gen(function* () { terminalId, cwd, async: script.async !== false, - ...(observed ? { completion: observed.completion } : {}), + ...(completion ? { completion } : {}), } as const; }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index d6ddb0b9263f..5d75e1a3d6d5 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -94,6 +94,8 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const first = yield* resolver.resolve("/repo/packages/web"); rootPath = "/repo/packages/web"; + // Longer than the one-minute cadence of the background sweeps. + yield* TestClock.adjust(Duration.minutes(10)); const second = yield* resolver.resolve("/repo/packages/web"); expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -123,10 +125,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); expect(unavailable?.webUrl).toBeUndefined(); expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); - it.effect("retries Git root discovery after a failed lookup", () => { + it.effect("retries Git root discovery after the negative TTL", () => { const calls: Array> = []; let rootAttempts = 0; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { @@ -159,7 +161,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + yield* TestClock.adjust(RepositoryIdentityResolver.DEFAULT_NEGATIVE_CACHE_TTL); const recovered = yield* resolver.resolve("/repo/packages/web"); expect(recovered?.rootPath).toBe("/repo"); expect(calls).toEqual([ @@ -167,7 +171,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], ]); - }).pipe(Effect.provide(resolverLayer)); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), resolverLayer))); }); it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index b5e4c4be46f1..1aaaa4a4722c 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -13,11 +13,12 @@ import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -// Turbo (shell-snapshot-budget): upstream keeps both at 1 minute, so every -// shell snapshot after a quiet minute re-spawns `git rev-parse` and -// `git remote -v` for every project. A repository's remote does not change -// minute to minute; the cached identity is only cosmetic (PR links, icons) -// and a restart or an explicit refresh still re-resolves it. +// Turbo (shell-snapshot-budget): upstream uses 15 minutes positive / 1 minute +// negative, so shell snapshots after a quiet window still re-spawn +// `git rev-parse` and `git remote -v` for every project. A repository's remote +// does not change minute to minute; the cached identity is only cosmetic (PR +// links, icons). Clone, publish, and PR discovery (after a turn and before it +// saves links) resolve with `refresh: true`, and a restart re-resolves too. export const DEFAULT_POSITIVE_CACHE_TTL = Duration.hours(12); export const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(10); @@ -146,53 +147,49 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( ) { const processRunner = yield* ProcessRunner.ProcessRunner; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + const refine = options.refine ?? Effect.succeed; + // Git errors and timeouts resolve to null, so they use the negative TTL like + // "no repository" or "no remote". Only interrupts and defects skip the cache. + const timeToLive = (exit: Exit.Exit) => + Exit.match(exit, { + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }); const repositoryRootCache = yield* Cache.makeWith( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.flatMap((identity) => - identity !== null && options.refine - ? options.refine(identity).pipe(Effect.catch(() => Effect.succeed(identity))) - : Effect.succeed(identity), + Effect.filterOrElse( + (identity): identity is null => identity === null, + (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)), ), ), - { - capacity: cacheCapacity, - timeToLive: Exit.match({ - onSuccess: (value) => - value === null - ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) - : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), - onFailure: () => Duration.zero, - }), - }, + { capacity: cacheCapacity, timeToLive }, ); - const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( - "RepositoryIdentityResolver.resolve", - )(function* (cwd, options) { - if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); - const cacheKey = yield* Cache.get(repositoryRootCache, cwd); - if (cacheKey === null) return null; - if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); - return yield* Cache.get(repositoryIdentityCache, cacheKey); - }); + // Untraced because almost every call is a cache hit. The lookups that spawn + // git keep their own spans. + const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( + function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }, + ); return RepositoryIdentityResolver.of({ resolve }); }); diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index 105e6b09a317..4ad874b9090b 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -68,7 +68,7 @@ export const make = Effect.gen(function* () { function* (workspaceRoot) { const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME); const raw = yield* fileSystem.readFileString(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" @@ -87,7 +87,7 @@ export const make = Effect.gen(function* () { return Option.none(); } return yield* decodeT3ProjectFileJson(raw.value).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ SchemaError: (error) => logT3ProjectFileLoadError( diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e2bab831712a..de72ebcc4e23 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -342,6 +342,8 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { if (!profile) return yield* Effect.die("Expected a disposable validation profile."); profiles.add(profile); const helper = command.args[0] === "-e"; + // The runtime unpacks straight into the disposable profile. + if (!helper) expect(command.options.env?.TMPDIR).toBe(profile); const output = yield* Queue.unbounded(); const exited = yield* Deferred.make(); const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe( diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 24eb4e3d6df8..f3c3d2a4d70a 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -315,7 +315,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ); } const contents = yield* fs.readFileString(filePath); - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(contents); + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(contents); }); const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* ( @@ -476,6 +476,9 @@ 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. + tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ spawn: buildAntigravityAcpSpawnInput({ diff --git a/apps/server/src/provider/CodexAuthCallback.test.ts b/apps/server/src/provider/CodexAuthCallback.test.ts new file mode 100644 index 000000000000..0dafa54cd1f5 --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallback.test.ts @@ -0,0 +1,105 @@ +// @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 new file mode 100644 index 000000000000..3194e179e62c --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallback.ts @@ -0,0 +1,60 @@ +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"; + +/** A connected local environment receives the callback; only the remote environment owns tokens. */ +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/CodexAuthCallbackPage.ts b/apps/server/src/provider/CodexAuthCallbackPage.ts new file mode 100644 index 000000000000..66b5543523c0 --- /dev/null +++ b/apps/server/src/provider/CodexAuthCallbackPage.ts @@ -0,0 +1,42 @@ +import { providerAuthReturnUrl } from "@t3tools/shared/providerAuthReturnUrl"; + +export const codexAuthReturnUrl = providerAuthReturnUrl; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/gu, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, + ); + +export function codexAuthCallbackPage( + success: boolean, + returnUrl: string | undefined, + nonce: string, +) { + const destination = codexAuthReturnUrl(returnUrl); + const title = success ? "You're signed in" : "Sign-in couldn't finish"; + const description = success + ? destination + ? "Returning to T3 Code. You're ready to continue." + : "Return to T3 Code to continue. You can close this tab." + : "Return to T3 Code and try signing in again."; + return ` + +${escapeHtml(title)} · T3 Code +${success && destination ? `` : ""} +
T3 Code
+ +

${escapeHtml(title)}

${description}

+${destination ? `
Return to T3 Code` : ""} +
`; +} diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts new file mode 100644 index 000000000000..e7166968b498 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -0,0 +1,2063 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - Local mock OAuth server validates the browser callback boundary. +import * as NodeHttp from "node:http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as Fiber from "effect/Fiber"; +import * as Deferred from "effect/Deferred"; +import * as TestClock from "effect/testing/TestClock"; +import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; +import { FetchHttpClient } from "effect/unstable/http"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { layerTest as settingsLayerTest } from "../serverSettings.ts"; +import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; + +const assertSameCallback = (actual: string | null, expected: string | null) => { + const left = new URL(actual!); + const right = new URL(expected!); + assert.strictEqual(left.protocol, right.protocol); + assert.strictEqual(left.hostname, right.hostname); + assert.strictEqual(left.pathname, right.pathname); +}; +const environmentIds = new WeakMap, EnvironmentId>(); +const instanceId = ProviderInstanceId.make("managed-codex-test"); +const makeHarnessFor = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + bytes: Map = new Map(), + failAnalytics = false, +) { + const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); + environmentIds.set(bytes, environmentId); + const environment = ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(environmentId), + }); + const keys = yield* Effect.promise(() => generateKeyPair("RS256")); + const jwk = yield* Effect.promise(() => exportJWK(keys.publicKey)); + const untrustedKeys = yield* Effect.promise(() => generateKeyPair("RS256")); + const secrets = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(name))), + set: (name, value) => + Effect.sync(() => { + bytes.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + bytes.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("Host identity must come from the environment."), + }); + let authorize: URL | undefined; + let refreshes = 0; + let revoked = false; + let refreshError: string | undefined; + let revocationStatus = 200; + let codeError: string | undefined; + const revocations: URLSearchParams[] = []; + let transient = false; + let invalidNonce = false; + let identityFailure: "issuer" | "audience" | "signature" | undefined; + let mismatchedState = false; + let callbackClientId: string | undefined; + let subject = "user-test"; + let email = "hidden@example.test"; + let grantScope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; + let origin = ""; + const exchanges: URLSearchParams[] = []; + const authorizationRequests: URL[] = []; + const callbackResponses: { body: string; headers: Headers }[] = []; + let returnUrl = "http://localhost:7001/welcome"; + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeHttp.createServer(async (request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/discovery") { + response.end( + JSON.stringify({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + jwks_uri: `${origin}/jwks`, + revocation_endpoint: `${origin}/revoke`, + }), + ); + return; + } + if (request.url === "/jwks") { + response.end( + JSON.stringify({ keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }] }), + ); + return; + } + if (request.url === "/revoke") { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + revocations.push(new URLSearchParams(text)); + response.statusCode = revocationStatus; + response.end(); + return; + } + if (request.url === "/token") { + let text = ""; + for await (const chunk of request) text += chunk.toString(); + assert.strictEqual( + request.headers["content-type"], + "application/x-www-form-urlencoded", + ); + assert.isUndefined(request.headers.authorization); + const body = new URLSearchParams(text); + assert.isFalse(body.has("client_secret")); + assert.strictEqual(body.get("resource"), `${origin}/v1`); + exchanges.push(body); + if (body.get("grant_type") === "refresh_token") { + refreshes++; + if (transient) { + response.statusCode = 503; + response.end(JSON.stringify({ error: "temporarily_unavailable" })); + return; + } + if (revoked || refreshError) { + response.statusCode = 400; + response.end(JSON.stringify({ error: refreshError ?? "invalid_grant" })); + return; + } + response.end( + JSON.stringify({ + access_token: `access-${refreshes}`, + refresh_token: `refresh-${refreshes}`, + token_type: "Bearer", + expires_in: 3600, + scope: grantScope, + }), + ); + return; + } + if (codeError) { + response.statusCode = 400; + response.end(JSON.stringify({ error: codeError })); + return; + } + if (!authorize) { + response.statusCode = 400; + response.end("{}"); + return; + } + assertSameCallback( + body.get("redirect_uri"), + authorize.searchParams.get("redirect_uri"), + ); + assert.strictEqual( + NodeCrypto.createHash("sha256") + .update(body.get("code_verifier")!) + .digest("base64url"), + authorize.searchParams.get("code_challenge"), + ); + const token = await new SignJWT({ + nonce: invalidNonce ? "incorrect" : authorize.searchParams.get("nonce"), + email, + }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(identityFailure === "issuer" ? "https://untrusted-issuer.test" : origin) + .setAudience( + identityFailure === "audience" + ? "oaiapp_untrusted_audience" + : body.get("client_id")!, + ) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("1h") + .sign(identityFailure === "signature" ? untrustedKeys.privateKey : keys.privateKey); + response.end( + JSON.stringify({ + access_token: "initial-access", + refresh_token: "initial-refresh", + id_token: token, + token_type: "Bearer", + expires_in: 3600, + scope: grantScope, + }), + ); + return; + } + response.statusCode = 404; + response.end("{}"); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("mock address"); + origin = `http://127.0.0.1:${address.port}`; + const analyticsEvents: { + event: string; + properties: Readonly> | undefined; + }[] = []; + const analytics = AnalyticsService.of({ + record: (event, properties) => + failAnalytics + ? Effect.die("analytics unavailable") + : Effect.sync(() => { + analyticsEvents.push({ event, properties }); + }), + flush: Effect.void, + }); + const auth = yield* makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(AnalyticsService, analytics), + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, environment), + ); + const phase = (phase: string) => + auth.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + const prepareCallback = (waiting: { authorizationUrl: string | null }) => { + authorize = new URL(waiting.authorizationUrl!); + authorizationRequests.push(authorize); + const callback = new URL(authorize.searchParams.get("redirect_uri")!); + callback.search = new URLSearchParams({ + code: "authorization-code", + state: mismatchedState ? "unmatched-state" : authorize.searchParams.get("state")!, + ...(callbackClientId + ? { client_id: callbackClientId } + : authorize.searchParams.get("client_id") === "dynamic_agent_client" + ? { client_id: "oaiapp_test" } + : {}), + }).toString(); + return callback; + }; + const startRemote = (methodId?: string) => + Effect.gen(function* () { + yield* auth.controller.start("owner", Effect.void, methodId, returnUrl, "client"); + const waiting = yield* phase("waiting"); + return { waiting, callbackUrl: prepareCallback(waiting).toString() }; + }); + const signInWithMethod = (methodId?: string) => + Effect.gen(function* () { + yield* auth.controller.start("owner", Effect.void, methodId, returnUrl); + const waiting = yield* phase("waiting"); + assert.strictEqual(waiting.interaction?.type, "browser"); + const callback = prepareCallback(waiting); + yield* Effect.promise(async () => { + const response = await fetch(callback); + callbackResponses.push({ body: await response.text(), headers: response.headers }); + }); + }); + const seedExpired = Effect.gen(function* () { + const stored = yield* auth.read; + const record = Option.getOrThrow(stored); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService(ServerSecretStore, secrets), + ); + yield* store.set(new TextEncoder().encode(JSON.stringify({ ...record, expiresAt: 0 }))); + }); + return { + auth, + analyticsEvents, + secrets, + environmentId, + bytes, + handoff: (profile: Parameters[0]["profile"]) => + subscribeChatGptHandoff( + { + instanceId, + environmentId, + attemptId: "test-handoff", + returnUrl, + profile, + }, + "owner", + { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, + ).pipe(Stream.provideService(AnalyticsService, analytics)), + finishCallback: (state: { authorizationUrl: string | null }) => + Effect.promise(() => fetch(prepareCallback(state))), + destination: Effect.gen(function* () { + const destinationBytes = new Map(); + const destinationStore = ServerSecretStore.of({ + ...secrets, + get: (name) => Effect.sync(() => Option.fromUndefinedOr(destinationBytes.get(name))), + set: (name, value) => + Effect.sync(() => { + destinationBytes.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + destinationBytes.delete(name); + }), + }); + const destination = yield* makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(AnalyticsService, analytics), + Effect.provideService(ServerSecretStore, destinationStore), + Effect.provideService(ServerEnvironmentIdentity, environment), + ); + return { auth: destination, bytes: destinationBytes }; + }), + recreateAuth: makeCodexChatGptAuth({ + instanceId, + discoveryUrl: `${origin}/discovery`, + resource: `${origin}/v1`, + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, environment), + ), + startRemote, + signIn: signInWithMethod(), + changeAccount: signInWithMethod("chatgpt-change-account"), + reconnectProfile: (clientId: string) => signInWithMethod(`chatgpt-profile:${clientId}`), + phase, + seedExpired, + exchanges, + authorizationRequests, + callbackResponses, + setReturnUrl: (value: string) => { + returnUrl = value; + }, + origin, + storedRecords: () => + Array.from(bytes.entries()).flatMap(([, value]) => { + const record = JSON.parse(new TextDecoder().decode(value)); + return record.sessions ?? [record]; + }), + revocations, + setRefreshError: (value: string) => { + refreshError = value; + }, + setRevocationStatus: (value: number) => { + revocationStatus = value; + }, + setCodeError: (value: string | undefined) => { + codeError = value; + }, + refreshes: () => refreshes, + setRevoked: () => { + revoked = true; + }, + setTransient: (value: boolean) => { + transient = value; + }, + setInvalidNonce: () => { + invalidNonce = true; + }, + setIdentityFailure: (value: "issuer" | "audience" | "signature") => { + identityFailure = value; + }, + mismatchCallbackState: () => { + mismatchedState = true; + }, + setCallbackClientId: (value: string) => { + callbackClientId = value; + }, + setIdentity: (newSubject: string, newEmail: string) => { + subject = newSubject; + email = newEmail; + }, + declineSharing: () => { + grantScope = "openid profile email"; + }, + }; +}); +const makeHarness = makeHarnessFor(instanceId); +const provision = (effect: Effect.Effect) => + effect.pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), + ); +it.effect( + "changing account registers a new user-owned client and then reuses that registration", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_other_account"); + const saved = Option.getOrThrow(yield* h.auth.read); + assert.strictEqual(saved.clientId, "oaiapp_other_account"); + assert.strictEqual(saved.subject, "other-user"); + assert.strictEqual(saved.email, "other@example.test"); + const redirectUri = h.authorizationRequests[1]!.searchParams.get("redirect_uri"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("client_id"), + "oaiapp_other_account", + ); + assertSameCallback( + h.authorizationRequests[2]!.searchParams.get("redirect_uri"), + redirectUri, + ); + }), + ), +); +for (const failure of ["identity", "sharing"] as const) { + it.effect( + `failed account change preserves the original credentials and registration: ${failure}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_other_account"); + if (failure === "identity") h.setInvalidNonce(); + else h.declineSharing(); + yield* h.changeAccount; + yield* h.phase("failed"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + if (failure === "sharing") { + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.accessToken), + ); + assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); + } else assert.deepEqual(h.storedRecords(), before); + }), + ), + ); +} +it.effect("retains the callback host and path after controller recreation and token removal", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + yield* first.auth.revoke; + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual( + restarted.exchanges[0]!.get("redirect_uri"), + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + ); + }), + ), +); +it.effect("reauthorizes on an available port when the original callback port is occupied", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const port = Number( + new URL(h.authorizationRequests[0]!.searchParams.get("redirect_uri")!).port, + ); + yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve, reject) => { + const server = NodeHttp.createServer(); + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => + Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), + ); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.notStrictEqual( + Number(new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).port), + port, + ); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(h.exchanges.length, 2); + }), + ), +); + +it.effect("registers a fresh client when the original registration is no longer stored", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) bytes.delete(key); + } + h.setCallbackClientId("oaiapp_replacement"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); + }), + ), +); +it.effect("reauthorizes a registration without persisting its original port", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.auth.revoke; + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set(key, new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test" }))); + } + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.strictEqual(new URL(h.exchanges[1]!.get("redirect_uri")!).hostname, "127.0.0.1"); + }), + ), +); + +it.effect( + "separate Codex accounts register independently and disconnect only their own tokens", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const personal = yield* makeHarnessFor(ProviderInstanceId.make("codex_personal"), bytes); + const work = yield* makeHarnessFor(ProviderInstanceId.make("codex_work"), bytes); + yield* personal.signIn; + yield* personal.phase("succeeded"); + const personalCredentials = Option.getOrThrow(yield* personal.auth.read); + assert.isTrue(Option.isNone(yield* work.auth.read)); + yield* work.signIn; + yield* work.phase("succeeded"); + assert.strictEqual( + work.authorizationRequests[0]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.notStrictEqual( + personal.auth.controller.credentialBinding?.key, + work.auth.controller.credentialBinding?.key, + ); + yield* work.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* work.auth.read)); + assert.deepEqual(Option.getOrThrow(yield* personal.auth.read), personalCredentials); + }), + ), +); +it.effect( + "emits the guide's first-time authorization request and fresh reauthorization values", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=http://localhost:7001/welcome"', + ); + const first = h.authorizationRequests[0]!; + assert.strictEqual( + first.searchParams.get("ext_agent_host_id"), + `urn:uuid:${h.environmentId}`, + ); + assert.strictEqual(first.origin, h.origin); + assert.strictEqual(first.pathname, "/authorize"); + assert.deepEqual(Array.from(first.searchParams.keys()).sort(), [ + "agent_name_hint", + "client_id", + "code_challenge", + "code_challenge_method", + "ext_agent_host_id", + "nonce", + "redirect_uri", + "resource", + "response_type", + "scope", + "state", + ]); + assert.strictEqual(first.searchParams.get("client_id"), "dynamic_agent_client"); + assert.strictEqual(first.searchParams.get("agent_name_hint"), "T3 Code"); + assert.strictEqual(first.searchParams.get("response_type"), "code"); + assert.strictEqual( + first.searchParams.get("scope"), + "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", + ); + assert.strictEqual(first.searchParams.get("resource"), `${h.origin}/v1`); + assert.strictEqual(first.searchParams.get("code_challenge_method"), "S256"); + const callback = new URL(first.searchParams.get("redirect_uri")!); + assert.strictEqual(callback.protocol, "http:"); + assert.strictEqual(callback.hostname, "127.0.0.1"); + assert.strictEqual(callback.pathname, "/auth/callback"); + assert.isAbove(Number(callback.port), 0); + for (const key of ["state", "nonce", "code_challenge"]) { + assert.match(first.searchParams.get(key)!, /^[A-Za-z0-9_-]{43}$/u); + } + assert.notStrictEqual(first.searchParams.get("state"), first.searchParams.get("nonce")); + const originalIdToken = Option.getOrThrow(yield* h.auth.read).idToken; + yield* h.signIn; + yield* h.phase("succeeded"); + const second = h.authorizationRequests[1]!; + assert.strictEqual(second.searchParams.get("client_id"), "oaiapp_test"); + assert.isFalse(second.searchParams.has("agent_name_hint")); + assert.strictEqual(second.searchParams.get("login_hint"), "hidden@example.test"); + assert.strictEqual(second.searchParams.get("id_token_hint"), originalIdToken); + assert.strictEqual( + second.searchParams.get("ext_agent_host_id"), + first.searchParams.get("ext_agent_host_id"), + ); + assertSameCallback( + second.searchParams.get("redirect_uri"), + first.searchParams.get("redirect_uri"), + ); + assert.deepEqual( + Array.from(second.searchParams.keys()).sort(), + [ + ...Array.from(first.searchParams.keys()).filter((key) => key !== "agent_name_hint"), + "login_hint", + "id_token_hint", + ].sort(), + ); + for (const key of ["state", "nonce", "code_challenge"]) { + assert.notStrictEqual(first.searchParams.get(key), second.searchParams.get(key)); + } + yield* h.auth.controller.logout(Effect.void); + yield* h.signIn; + yield* h.phase("succeeded"); + const reconnect = h.authorizationRequests[2]!; + assert.strictEqual(reconnect.searchParams.get("client_id"), "oaiapp_test"); + assertSameCallback( + reconnect.searchParams.get("redirect_uri"), + first.searchParams.get("redirect_uri"), + ); + assert.isFalse(reconnect.searchParams.has("agent_name_hint")); + assert.isFalse(reconnect.searchParams.has("id_token_hint")); + assert.strictEqual(reconnect.searchParams.get("login_hint"), "hidden@example.test"); + assert.strictEqual( + reconnect.searchParams.get("ext_agent_host_id"), + first.searchParams.get("ext_agent_host_id"), + ); + assert.strictEqual( + h.exchanges[2]!.get("redirect_uri"), + reconnect.searchParams.get("redirect_uri"), + ); + }), + ), +); +it.effect( + "returns to the initiating client only after sign-in has verified and saved credentials", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl( + "http://localhost:7001/settings/providers?instanceId=codex_work&code=secret-code", + ); + yield* h.signIn; + assert.isTrue(Option.isSome(yield* h.auth.read)); + const response = h.callbackResponses[0]!; + assert.include(response.headers.get("content-type")!, "text/html"); + assert.strictEqual(response.headers.get("cache-control"), "no-store"); + assert.strictEqual(response.headers.get("referrer-policy"), "no-referrer"); + assert.include(response.headers.get("content-security-policy")!, "default-src 'none'"); + assert.include(response.body, "You're signed in".replace("'", "'")); + assert.include( + response.body, + 'content="1;url=http://localhost:7001/settings/providers?instanceId=codex_work"', + ); + assert.notInclude(response.body, "secret-code"); + assert.include(response.body, 'history.replaceState(null,"","/auth/callback")'); + assert.notInclude(response.body, "authorization-code"); + assert.notInclude(response.body, "initial-access"); + assert.notInclude(response.body, "hidden@example.test"); + }), + ), +); +it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment"); + yield* h.signIn; + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=http://localhost:7001/welcome#agents:test-environment"', + ); + assert.notInclude(h.callbackResponses[0]!.body, "never-forward"); + }), + ), +); +it.effect( + "does not redirect to an arbitrary return URL or claim success after verification fails", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("https://attacker.example/welcome"); + h.setInvalidNonce(); + yield* h.signIn; + yield* h.phase("failed"); + const response = h.callbackResponses[0]!; + assert.include(response.body, "Sign-in couldn't finish"); + assert.notInclude(response.body, 'http-equiv="refresh"'); + assert.notInclude(response.body, "attacker.example"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect( + "verifies registration, PKCE, identity and persists rotating refresh before concurrent access", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); + assert.strictEqual(h.exchanges[0]?.get("client_id"), "oaiapp_test"); + yield* h.seedExpired; + const records = yield* Effect.all([h.auth.access, h.auth.access], { + concurrency: "unbounded", + }); + assert.strictEqual(h.refreshes(), 1); + assert.strictEqual(records[0].accessToken, "access-1"); + assert.strictEqual(records[1].refreshToken, "refresh-1"); + assert.isNull(h.exchanges[1]?.get("scope")); + yield* h.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect("rejects invalid ID token nonce without saving credentials", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setInvalidNonce(); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +it.effect("retains identity after declined sharing but never admits inference", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.declineSharing(); + yield* h.signIn; + yield* h.phase("failed"); + assert.isTrue(Option.isSome(yield* h.auth.read)); + const result = yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.strictEqual(h.refreshes(), 0); + }), + ), +); +it.effect("clears revoked refresh credentials and does not replay them", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRevoked(); + yield* h.auth.access.pipe(Effect.result); + yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(h.refreshes(), 1); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); + +it.effect( + "preserves credentials through temporary renewal failure and retries the latest refresh", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setTransient(true); + yield* h.auth.access.pipe(Effect.result); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).refreshToken, "initial-refresh"); + h.setTransient(false); + const renewed = yield* h.auth.access; + assert.strictEqual(renewed.refreshToken, "refresh-2"); + assert.strictEqual(h.exchanges.at(-1)?.get("refresh_token"), "initial-refresh"); + }), + ), +); + +it.effect( + "disconnect retains the remembered account profile while a different account registers afresh", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[0]?.searchParams.get("client_id"), + "dynamic_agent_client", + ); + yield* h.auth.controller.logout(Effect.void); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.deepEqual(h.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + sharingEnabled: true, + redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + const disconnected = yield* h.auth.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.include( + disconnected.methods!.find((method) => method.id === "chatgpt")!.description!, + "hidden@example.test", + ); + assert.strictEqual( + disconnected.methods!.find((method) => method.id === "chatgpt")!.accountEmail, + "hidden@example.test", + ); + assert.strictEqual( + disconnected.methods!.find((method) => method.id === "chatgpt-profile:oaiapp_test")! + .accountEmail, + "hidden@example.test", + ); + assert.strictEqual((yield* h.auth.access.pipe(Effect.result))._tag, "Failure"); + assert.strictEqual(h.refreshes(), 0); + h.setCallbackClientId("oaiapp_different_workspace"); + h.setIdentity("different-user", "different@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]?.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(h.exchanges[1]?.get("client_id"), "oaiapp_different_workspace"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "different-user"); + }), + ), +); +it.effect("revoked connection clears tokens but preserves registration for reconnect", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRevoked(); + yield* h.auth.access.pipe(Effect.result); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.deepEqual(h.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + sharingEnabled: true, + redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); + }), + ), +); + +it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.auth.revoke; + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.strictEqual(h.storedRecords().length, 1); + h.setCallbackClientId("oaiapp_new_workspace"); + h.setIdentity("new-workspace-user", "new@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user"); + }), + ), +); + +it.effect("rejects mismatched callback state before any token exchange or credential write", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.mismatchCallbackState(); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 0); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); +for (const invalidClaim of ["issuer", "audience", "signature"] as const) { + it.effect( + `rejects ID token ${invalidClaim} verification before saving tokens or registration`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setIdentityFailure(invalidClaim); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "could not be verified"); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), []); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), + ); +} +for (const revoked of [false, true]) { + it.effect( + `rejects conflicting callback client ID on reauthorization ${revoked ? "after token removal" : "without changing the current account"}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (revoked) yield* h.auth.revoke; + const before = h.storedRecords(); + h.setCallbackClientId("oaiapp_untrusted_callback"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); + assert.strictEqual( + h.authorizationRequests[1]?.searchParams.get("client_id"), + "oaiapp_test", + ); + assert.strictEqual(h.exchanges.length, 1); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); + }), + ), + ); +} + +it.effect("returns successful desktop sign-in to the original Welcome step", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.include( + h.callbackResponses[0]!.body, + 'content="1;url=t3code-dev://app/welcome#agents:test-environment"', + ); + assert.include( + h.callbackResponses[0]!.body, + 'href="t3code-dev://app/welcome#agents:test-environment"', + ); + }), + ), +); + +it.effect( + "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization", + () => + Effect.gen(function* () { + const harness = yield* makeHarnessFor(instanceId); + const first = yield* harness.startRemote(); + assert.isTrue( + first.waiting.interaction?.type === "browser" && first.waiting.interaction.acceptsCallback, + ); + yield* harness.auth.controller.complete("owner", { + flowId: first.waiting.flowId!, + callbackUrl: first.callbackUrl, + }); + yield* harness.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* harness.auth.read).email, "hidden@example.test"); + const second = yield* harness.startRemote(); + assertSameCallback(second.callbackUrl, first.callbackUrl); + assert.strictEqual( + new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), + "oaiapp_test", + ); + yield* harness.auth.controller.complete("owner", { + flowId: second.waiting.flowId!, + callbackUrl: second.callbackUrl, + }); + yield* harness.phase("succeeded"); + assert.strictEqual(harness.exchanges.length, 2); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), +); + +it.effect( + "rejects foreign clients and malformed remote callbacks without consuming the owner's sign-in", + () => + Effect.gen(function* () { + const harness = yield* makeHarnessFor(instanceId); + const { waiting, callbackUrl } = yield* harness.startRemote(); + assert.isTrue(Option.isNone(yield* harness.auth.read)); + yield* Effect.flip( + harness.auth.controller.complete("other-client", { flowId: waiting.flowId!, callbackUrl }), + ); + const wrongPort = new URL(callbackUrl); + wrongPort.port = wrongPort.port === "65535" ? "65534" : "65535"; + for (const invalid of [ + wrongPort.toString(), + callbackUrl.replace("/auth/callback", "/other"), + callbackUrl + "&state=foreign", + callbackUrl + "&code=duplicate", + callbackUrl + "&error=access_denied", + callbackUrl.replace("127.0.0.1", "attacker.example"), + ]) { + yield* Effect.flip( + harness.auth.controller.complete("owner", { + flowId: waiting.flowId!, + callbackUrl: invalid, + }), + ); + } + assert.strictEqual(harness.exchanges.length, 0); + assert.isTrue(Option.isNone(yield* harness.auth.read)); + yield* harness.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl }); + yield* harness.phase("succeeded"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), + ), +); + +it.effect("keeps simultaneous remote accounts and environments independent", () => + Effect.gen(function* () { + const first = yield* makeHarnessFor(instanceId); + const second = yield* makeHarnessFor(instanceId); + second.setIdentity("other-user", "other@example.test"); + second.setCallbackClientId("oaiapp_other"); + const a = yield* first.startRemote(); + const b = yield* second.startRemote(); + assert.notStrictEqual( + new URL(a.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), + new URL(b.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), + ); + yield* Effect.flip( + first.auth.controller.complete("owner", { + flowId: a.waiting.flowId!, + callbackUrl: b.callbackUrl, + }), + ); + yield* first.auth.controller.complete("owner", { + flowId: a.waiting.flowId!, + callbackUrl: a.callbackUrl, + }); + yield* second.auth.controller.complete("owner", { + flowId: b.waiting.flowId!, + callbackUrl: b.callbackUrl, + }); + yield* first.phase("succeeded"); + yield* second.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test"); + assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user"); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), +); + +it.effect( + "reconnects after Disconnect and restart with the same client and host on an available port", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + yield* first.auth.controller.logout(Effect.void); + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual( + restarted.exchanges[0]!.get("redirect_uri"), + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + ); + assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); + }), + ), +); + +for (const disconnected of [false, true]) { + it.effect( + `rejects a different verified identity during saved-profile reauth ${disconnected ? "after Disconnect" : "while connected"}`, + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + if (disconnected) yield* h.auth.controller.logout(Effect.void); + const before = h.storedRecords(); + h.setIdentity("another-user", "another@example.test"); + yield* h.signIn; + assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); + assert.deepEqual(h.storedRecords(), before); + assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); + }), + ), + ); +} + +it.effect( + "retains both profiles and reuses the original account's client and callback when returning from another account", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + first.setIdentity("other-user", "other@example.test"); + first.setCallbackClientId("oaiapp_other_account"); + yield* first.changeAccount; + const changed = yield* first.phase("succeeded"); + assert.isTrue( + changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test"), + ); + assert.isTrue( + changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_other_account"), + ); + yield* first.auth.controller.logout(Effect.void); + const restarted = yield* makeHarnessFor(instanceId, bytes); + yield* restarted.reconnectProfile("oaiapp_test"); + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); + }), + ), +); + +it.effect("fresh sign-in to the same identity preserves separate registration profiles", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "other@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + h.setIdentity("user-test", "hidden@example.test"); + h.setCallbackClientId("oaiapp_replacement"); + yield* h.changeAccount; + const changed = yield* h.phase("succeeded"); + assert.deepEqual( + changed + .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) + .map((method) => method.id), + [ + "chatgpt-profile:oaiapp_replacement", + "chatgpt-profile:oaiapp_other_account", + "chatgpt-profile:oaiapp_test", + ], + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); + const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri"); + yield* h.auth.controller.logout(Effect.void); + yield* h.reconnectProfile("oaiapp_replacement"); + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[3]!.searchParams.get("client_id"), + "oaiapp_replacement", + ); + assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri); + }), + ), +); + +it.effect("preserves legacy profiles with the same email and subject", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + const redirectUri = h.authorizationRequests[0]!.searchParams.get("redirect_uri"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set( + key, + new TextEncoder().encode( + JSON.stringify({ + ...record, + profiles: [ + { ...record.profiles[0], clientId: "oaiapp_old_duplicate" }, + ...record.profiles, + ], + }), + ), + ); + } + const restarted = yield* makeHarnessFor(instanceId, bytes); + const state = yield* restarted.auth.controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods != null), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual( + state + .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) + .map((method) => method.id), + ["chatgpt-profile:oaiapp_test", "chatgpt-profile:oaiapp_old_duplicate"], + ); + yield* restarted.signIn; + yield* restarted.phase("succeeded"); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), + redirectUri, + ); + assert.lengthOf(restarted.storedRecords().find((record) => record.profiles).profiles, 2); + }), + ), +); + +it.effect("keeps distinct verified identities even when their email matches", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "hidden@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + const changed = yield* h.phase("succeeded"); + assert.lengthOf( + changed.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")), + 2, + ); + }), + ), +); + +it.effect( + "migrates a legacy registration and retains its verified identity when credentials are cleared", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const first = yield* makeHarnessFor(instanceId, bytes); + yield* first.signIn; + yield* first.phase("succeeded"); + const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); + for (const [key, value] of bytes) { + const saved = JSON.parse(new TextDecoder().decode(value)); + if (saved.profiles) + bytes.set( + key, + new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test", redirectUri })), + ); + } + yield* first.auth.revoke; + assert.deepEqual(first.storedRecords(), [ + { + profiles: [ + { + clientId: "oaiapp_test", + connectionLabel: "Connection 1", + redirectUri, + subject: "user-test", + email: "hidden@example.test", + }, + ], + lastClientId: "oaiapp_test", + }, + ]); + const restarted = yield* makeHarnessFor(instanceId, bytes); + restarted.setIdentity("another-user", "another@example.test"); + yield* restarted.signIn; + assert.include((yield* restarted.phase("failed")).message!, "different ChatGPT account"); + assert.isTrue(Option.isNone(yield* restarted.auth.read)); + assert.strictEqual( + restarted.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), + first.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), + ); + }), + ), +); + +it.effect("declined sharing on an older profile does not replace the active account", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setIdentity("other-user", "other@example.test"); + h.setCallbackClientId("oaiapp_other_account"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + const before = h.storedRecords(); + h.setIdentity("user-test", "hidden@example.test"); + h.setCallbackClientId("oaiapp_test"); + h.declineSharing(); + yield* h.reconnectProfile("oaiapp_test"); + assert.include( + (yield* h.phase("failed")).message!, + "existing ChatGPT connection is unchanged", + ); + assert.deepEqual( + Option.getOrThrow(yield* h.auth.read), + before.find((record) => record.clientId === "oaiapp_other_account"), + ); + }), + ), +); + +it.effect( + "Disconnect preserves a remote profile's callback URI for client-delivered reconnect", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const first = yield* h.startRemote(); + yield* h.auth.controller.complete("owner", { + flowId: first.waiting.flowId!, + callbackUrl: first.callbackUrl, + }); + yield* h.phase("succeeded"); + yield* h.auth.controller.logout(Effect.void); + const second = yield* h.startRemote(); + assert.strictEqual( + new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), + "oaiapp_test", + ); + assertSameCallback( + new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"), + new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"), + ); + yield* h.auth.controller.complete("owner", { + flowId: second.waiting.flowId!, + callbackUrl: second.callbackUrl, + }); + yield* h.phase("succeeded"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); + }), + ), +); + +for (const code of [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + "invalid_client", + "invalid_token", +]) { + it.effect(`refresh recovery follows the machine-readable code: ${code}`, () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.seedExpired; + h.setRefreshError(code); + yield* Effect.flip(h.auth.access); + assert.strictEqual( + Option.isNone(yield* h.auth.read), + !["invalid_client", "invalid_token"].includes(code), + ); + assert.isTrue( + h + .storedRecords() + .some((record) => + record.profiles?.some( + (profile: { clientId: string }) => profile.clientId === "oaiapp_test", + ), + ), + ); + }), + ), + ); +} + +it.effect( + "logout revokes the latest refresh token with the selected client and clears its ID hint", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const host = h.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"); + assert.match(host!, /^urn:uuid:[0-9a-f-]{36}$/u); + yield* h.seedExpired; + yield* h.auth.access; + const state = yield* h.auth.controller.logout(Effect.void); + assert.strictEqual(state.message, "Signed out."); + assert.deepEqual(Array.from(h.revocations[0]!), [ + ["token", "refresh-1"], + ["token_type_hint", "refresh_token"], + ["client_id", "oaiapp_test"], + ]); + assert.isTrue(Option.isNone(yield* h.auth.read)); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("ext_agent_host_id"), host); + }), + ), +); + +it.live("logout retries temporary revocation failures and reports local-only sign-out", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setRevocationStatus(503); + const state = yield* h.auth.controller.logout(Effect.void); + assert.lengthOf(h.revocations, 3); + assert.strictEqual(state.phase, "idle"); + assert.include(state.message!, "Remote revocation could not be confirmed"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.isFalse(h.storedRecords().some((record) => record.idToken)); + }), + ), +); + +it.effect( + "reauth hints come from the selected profile, including after another profile logs out", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const personal = Option.getOrThrow(yield* h.auth.read); + h.setCallbackClientId("oaiapp_work"); + h.setIdentity("work-user", "work@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("login_hint")); + yield* h.auth.controller.logout(Effect.void); + h.setCallbackClientId("oaiapp_test"); + h.setIdentity("user-test", "hidden@example.test"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("id_token_hint"), + personal.idToken, + ); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("login_hint"), + personal.email, + ); + }), + ), +); + +it.effect("an expired initial code retains the issued ID for a fresh authorization", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.setCodeError("invalid_grant"); + yield* h.signIn; + const failed = yield* h.phase("failed"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + assert.isTrue(failed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test")); + h.setCodeError(undefined); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); + assert.notStrictEqual( + h.authorizationRequests[0]!.searchParams.get("state"), + h.authorizationRequests[1]!.searchParams.get("state"), + ); + }), + ), +); + +it.effect("identity-only sign-in requests consent on an explicit retry, then rechecks scopes", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + h.declineSharing(); + yield* h.signIn; + yield* h.phase("failed"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("failed"); + assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("prompt"), "consent"); + assert.isFalse(h.authorizationRequests[1]!.searchParams.has("force_reconsent")); + assert.include( + h.authorizationRequests[1]!.searchParams.get("scope")!, + "chatgpt.tokens.use.direct", + ); + yield* Effect.flip(h.auth.access); + }), + ), +); + +it.effect( + "controller replacement shares refresh serialization for the same environment session", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const replacement = yield* h.recreateAuth; + yield* h.seedExpired; + const records = yield* Effect.all([h.auth.access, replacement.access], { + concurrency: "unbounded", + }); + assert.strictEqual(h.refreshes(), 1); + assert.strictEqual(records[0].refreshToken, "refresh-1"); + assert.deepEqual(records[0], records[1]); + }), + ), +); + +it.effect( + "old localhost registrations retain their profile while a new client gets its own identity", + () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const h = yield* makeHarnessFor(instanceId, bytes); + yield* h.signIn; + yield* h.phase("succeeded"); + for (const [key, value] of bytes) { + const record = JSON.parse(new TextDecoder().decode(value)); + if (record.profiles) + bytes.set( + key, + new TextEncoder().encode( + JSON.stringify({ + ...record, + profiles: record.profiles.map((profile: { redirectUri: string }) => ({ + ...profile, + redirectUri: profile.redirectUri.replace("127.0.0.1", "localhost"), + })), + }), + ), + ); + } + h.setCallbackClientId("oaiapp_migrated"); + h.setIdentity("new-client-subject", "hidden@example.test"); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[1]!.searchParams.get("client_id"), + "dynamic_agent_client", + ); + assert.strictEqual( + new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).hostname, + "127.0.0.1", + ); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-client-subject"); + const profiles = (yield* h.phase("succeeded")).methods!; + assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_test")); + assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_migrated")); + yield* h.signIn; + yield* h.phase("succeeded"); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("client_id"), + "oaiapp_migrated", + ); + assert.strictEqual( + h.authorizationRequests[2]!.searchParams.get("login_hint"), + "hidden@example.test", + ); + assert.isTrue(h.authorizationRequests[2]!.searchParams.has("id_token_hint")); + }), + ), +); + +it.effect("keeps the active connection first when another profile declines sharing", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_identity_only"); + h.setIdentity("other-user", "other@example.test"); + h.declineSharing(); + yield* h.changeAccount; + const state = yield* h.phase("failed"); + assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test"); + const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")); + assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test"); + assert.include(profiles[0]!.name, "Connection 1"); + assert.include(profiles[1]!.name, "Connection 2"); + }), + ), +); + +it.effect("primary completes OAuth and destination imports and owns the refresh session", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const destination = yield* h.destination; + const before = h.bytes.size; + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const transferred = yield* h.handoff(null).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.filter((state) => state.phase === "finished"), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* h.finishCallback(yield* Deferred.await(waiting)); + const result = yield* Fiber.join(transferred); + assert.strictEqual(result.phase, "finished"); + if (result.phase !== "finished") return; + assert.strictEqual(h.bytes.size, before); + assert.isTrue(Option.isNone(yield* h.auth.read)); + let stopped = false; + const imported = yield* destination.auth.controller.importProfile!( + result.profile, + Effect.sync(() => { + stopped = true; + }), + ); + assert.isTrue(stopped); + assert.strictEqual(imported.phase, "succeeded"); + assert.deepStrictEqual( + h.analyticsEvents.map(({ event }) => event), + [ + "chatgpt.auth.started", + "chatgpt.auth.completed", + "chatgpt.transfer.started", + "chatgpt.transfer.completed", + ], + ); + assert.isTrue( + h.analyticsEvents.every(({ properties }) => properties?.flow === "primary_handoff"), + ); + assert.strictEqual(h.analyticsEvents[1]?.properties?.outcome, "succeeded"); + assert.strictEqual(h.analyticsEvents[3]?.properties?.outcome, "succeeded"); + assert.notProperty(h.analyticsEvents[1]?.properties ?? {}, "connectedAccountCount"); + assert.strictEqual(h.analyticsEvents[1]?.properties?.intent, "different_account"); + assert.strictEqual(h.analyticsEvents[3]?.properties?.connectedAccountCount, 1); + assert.strictEqual(h.analyticsEvents[3]?.properties?.savedConnectionCount, 1); + const saved = Option.getOrThrow(yield* destination.auth.read); + assert.strictEqual(saved.clientId, result.profile.registration.clientId); + assert.strictEqual(saved.refreshToken, "initial-refresh"); + const reconnect = yield* destination.auth.controller.reconnectProfile!("chatgpt"); + assert.strictEqual(reconnect?.idTokenHint, result.profile.credentials.idToken); + assert.isFalse("refreshToken" in reconnect!); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService( + ServerSecretStore, + ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(destination.bytes.get(name))), + set: (name, value) => + Effect.sync(() => { + destination.bytes.set(name, value); + }), + remove: () => Effect.void, + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }), + ), + ); + yield* store.set(new TextEncoder().encode(JSON.stringify({ ...saved, expiresAt: 0 }))); + yield* destination.auth.access; + assert.strictEqual( + h.exchanges.filter((entry) => entry.get("grant_type") === "authorization_code").length, + 1, + ); + assert.strictEqual( + h.exchanges.filter((entry) => entry.get("grant_type") === "refresh_token").length, + 1, + ); + }), + ), +); + +it.effect("transferred profiles reject mismatched identities without overwriting credentials", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const profile = yield* h.auth.exportProfile; + const destination = yield* h.destination; + yield* destination.auth.controller.importProfile!(profile, Effect.void); + const original = Option.getOrThrow(yield* destination.auth.read); + for (const credentials of [ + { ...profile.credentials, subject: "wrong-user" }, + { ...profile.credentials, clientId: "oaiapp_wrong" }, + { ...profile.credentials, idToken: "not-a-jwt" }, + { ...profile.credentials, scopes: ["openid"] }, + { ...profile.credentials, expiresAt: 0 }, + ]) { + const result = yield* destination.auth.controller.importProfile!( + { ...profile, credentials }, + Effect.void, + ).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepEqual(Option.getOrThrow(yield* destination.auth.read), original); + } + }), + ), +); + +it.effect("primary handoff reuses the selected registration and ID token hint", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const profile = yield* h.auth.controller.reconnectProfile!("chatgpt"); + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const resultFiber = yield* h.handoff(profile).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.runCollect, + Effect.forkChild, + ); + const state = yield* Deferred.await(waiting); + const url = new URL(state.authorizationUrl!); + assert.strictEqual(url.searchParams.get("client_id"), profile!.clientId); + assert.strictEqual(url.searchParams.get("id_token_hint"), profile!.idTokenHint); + assert.strictEqual(url.searchParams.get("login_hint"), profile!.email); + assert.strictEqual(url.searchParams.get("ext_agent_host_id"), `urn:uuid:${h.environmentId}`); + yield* h.finishCallback(state); + const results = yield* Fiber.join(resultFiber); + assert.strictEqual(results.at(-1)?.phase, "finished"); + }), + ), +); + +it.effect( + "cancelling primary handoff closes its callback listener without saving credentials", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); + const flow = yield* h.handoff(null).pipe( + Stream.tap((state) => + state.phase === "auth" && state.state.phase === "waiting" + ? Deferred.succeed(waiting, state.state) + : Effect.void, + ), + Stream.runDrain, + Effect.forkChild, + ); + const state = yield* Deferred.await(waiting); + yield* Fiber.interrupt(flow); + const callback = new URL( + new URL(state.authorizationUrl!).searchParams.get("redirect_uri")!, + ); + const request = yield* Effect.tryPromise(() => fetch(callback)).pipe(Effect.result); + assert.strictEqual(request._tag, "Failure"); + assert.isTrue(Option.isNone(yield* h.auth.read)); + }), + ), +); + +it.effect("records one anonymous auth outcome per success, failure, or cancellation", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + h.setInvalidNonce(); + yield* h.signIn; + yield* h.phase("failed"); + const { waiting } = yield* h.startRemote(); + yield* h.auth.controller.cancel("owner", waiting.flowId!); + yield* h.phase("cancelled"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.deepStrictEqual( + completed.map(({ properties }) => properties?.outcome), + ["succeeded", "failed", "cancelled"], + ); + assert.strictEqual( + h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.started").length, + 3, + ); + assert.strictEqual(completed[1]?.properties?.failureStage, "verify"); + for (const { properties } of completed) { + assert.isNumber(properties?.durationMs); + assert.strictEqual(properties?.flow, "direct"); + assert.deepStrictEqual( + Object.keys(properties!).sort(), + [ + ...(properties?.outcome === "succeeded" + ? [ + "accountCountScope", + "connectedAccountCount", + "connectedConnectionCount", + "savedConnectionCount", + "unidentifiedConnectedConnectionCount", + ] + : []), + "callbackMode", + "durationMs", + ...(properties?.failureStage ? ["failureStage"] : []), + "flow", + "intent", + "outcome", + ].sort(), + ); + } + const serialized = JSON.stringify(h.analyticsEvents); + for (const secret of [ + "hidden@example.test", + "oaiapp_test", + "user-test", + "access_token", + "refresh_token", + ]) { + assert.notInclude(serialized, secret); + } + }), + ), +); + +it.effect("telemetry failures do not fail a verified ChatGPT sign-in", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarnessFor(instanceId, new Map(), true); + yield* h.signIn; + const state = yield* h.phase("succeeded"); + assert.strictEqual(state.phase, "succeeded"); + assert.isTrue(Option.isSome(yield* h.auth.read)); + }), + ), +); + +it.effect("counts accounts separately from saved connections across additions and reconnects", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + yield* h.signIn; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_same_account"); + h.setIdentity("another-client-subject", "HIDDEN@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + yield* h.auth.controller.logout(Effect.void); + h.setCallbackClientId("oaiapp_test"); + h.setIdentity("user-test", "hidden@example.test"); + yield* h.reconnectProfile("oaiapp_test"); + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.deepStrictEqual( + completed.map(({ properties }) => [ + properties?.connectedAccountCount, + properties?.connectedConnectionCount, + properties?.savedConnectionCount, + ]), + [ + [1, 1, 1], + [1, 1, 1], + [1, 2, 2], + [2, 3, 3], + [1, 2, 3], + ], + ); + assert.isTrue( + completed.every(({ properties }) => properties?.unidentifiedConnectedConnectionCount === 0), + ); + assert.notInclude(JSON.stringify(h.analyticsEvents), "example.test"); + }), + ), +); + +it.effect("includes accounts from other Codex instances in the environment", () => + provision( + Effect.gen(function* () { + const bytes = new Map(); + const personal = yield* makeHarnessFor(instanceId, bytes); + const work = yield* makeHarnessFor(ProviderInstanceId.make("managed-work"), bytes); + yield* personal.signIn; + yield* personal.phase("succeeded"); + work.setCallbackClientId("oaiapp_work"); + work.setIdentity("work-user", "work@example.test"); + yield* work.signIn; + yield* work.phase("succeeded"); + const completed = work.analyticsEvents.find( + ({ event }) => event === "chatgpt.auth.completed", + ); + assert.strictEqual(completed?.properties?.accountCountScope, "environment"); + assert.strictEqual(completed?.properties?.connectedAccountCount, 2); + assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); + assert.strictEqual(completed?.properties?.savedConnectionCount, 2); + }).pipe( + Effect.provide( + settingsLayerTest({ + providerInstances: { + [instanceId]: { driver: "codex", enabled: true }, + [ProviderInstanceId.make("managed-work")]: { driver: "codex", enabled: true }, + }, + }), + ), + ), + ), +); + +it.effect("an unreadable unrelated profile omits counts without failing sign-in", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + const unrelated = yield* ProviderCredentialStore.make("codex-chatgpt", "codex").pipe( + Effect.provideService(ServerSecretStore, h.secrets), + ); + // The harness owns its store; seed the corresponding binding in that store. + h.bytes.set(unrelated.binding.key, new TextEncoder().encode("invalid")); + yield* h.signIn; + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.find(({ event }) => event === "chatgpt.auth.completed"); + assert.strictEqual(completed?.properties?.outcome, "succeeded"); + assert.notProperty(completed?.properties ?? {}, "connectedAccountCount"); + }).pipe(Effect.provide(settingsLayerTest())), + ), +); + +it.effect("reports connections without an email separately from identifiable accounts", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( + Effect.provideService(ServerSecretStore, h.secrets), + ); + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + activeClientId: "oaiapp_test", + sessions: [{ ...Option.getOrThrow(yield* h.auth.read), email: null }], + }), + ), + ); + h.setCallbackClientId("oaiapp_other_account"); + h.setIdentity("other-user", "other@example.test"); + yield* h.changeAccount; + yield* h.phase("succeeded"); + const completed = h.analyticsEvents.findLast( + ({ event }) => event === "chatgpt.auth.completed", + ); + assert.strictEqual(completed?.properties?.connectedAccountCount, 1); + assert.strictEqual(completed?.properties?.connectedConnectionCount, 2); + assert.strictEqual(completed?.properties?.unidentifiedConnectedConnectionCount, 1); + }), + ), +); + +it.effect("records an expired auth outcome when sign-in reaches its deadline", () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.startRemote(); + yield* TestClock.adjust(300_001); + assert.include((yield* h.phase("failed")).message ?? "", "expired"); + const completed = h.analyticsEvents.filter(({ event }) => event === "chatgpt.auth.completed"); + assert.strictEqual(completed.length, 1); + assert.strictEqual(completed[0]?.properties?.outcome, "expired"); + assert.isAtLeast(completed[0]?.properties?.durationMs as number, 300_000); + }), + ), +); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts new file mode 100644 index 000000000000..365025dbb09c --- /dev/null +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -0,0 +1,985 @@ +// @effect-diagnostics nodeBuiltinImport:off - OAuth loopback listener and PKCE use Node APIs. +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { + ProviderSetupError, + type ChatGptReconnectProfile, + type ChatGptTransferredProfile, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import { AnalyticsService } from "../telemetry/AnalyticsService.ts"; +import * as Exit from "effect/Exit"; +import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; +import type { ProviderAuthFlowContext } from "./ProviderAuthFlow.ts"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; + +const isSetupError = Schema.is(ProviderSetupError); +const RESOURCE = "https://api.openai.com/v1"; +const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; +const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; +const TokenResponse = Schema.Struct({ + access_token: Schema.NonEmptyString, + refresh_token: Schema.optionalKey(Schema.NonEmptyString), + id_token: Schema.optionalKey(Schema.String), + token_type: Schema.String, + expires_in: Schema.Int.check(Schema.isGreaterThan(0)), + scope: Schema.String, + earliest_refresh_at: Schema.optionalKey(Schema.Union([Schema.Finite, Schema.String])), +}); +const Record = Schema.Struct({ + clientId: Schema.String, + accessToken: Schema.String, + refreshToken: Schema.NullOr(Schema.String), + expiresAt: Schema.Finite, + earliestRefreshAt: Schema.NullOr(Schema.Finite), + scopes: Schema.Array(Schema.String), + subject: Schema.String, + email: Schema.NullOr(Schema.String), + idToken: Schema.optionalKey(Schema.String), + issuer: Schema.optionalKey(Schema.String), +}); +export type CodexChatGptCredentials = typeof Record.Type; +const Sessions = Schema.Struct({ + activeClientId: Schema.NullOr(Schema.String), + sessions: Schema.Array(Record), +}); +const sessionLocks = new WeakMap< + typeof ServerSecretStore.Service, + Map +>(); +const Registration = Schema.Struct({ + clientId: Schema.String.check(Schema.isPattern(/^oaiapp_/u)), + subject: Schema.optionalKey(Schema.String), + connectionLabel: Schema.optionalKey(Schema.String), + sharingEnabled: Schema.optionalKey(Schema.Boolean), + email: Schema.optionalKey(Schema.NullOr(Schema.String)), + redirectUri: Schema.optionalKey( + Schema.String.check( + Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u), + ), + ), +}); +const RegistrationProfiles = Schema.Struct({ + profiles: Schema.Array(Registration), + lastClientId: Schema.NullOr(Schema.String), +}); +const decodeRegistration = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])), +); +const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles)); +const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`; +const Discovery = Schema.Struct({ + issuer: Schema.String, + authorization_endpoint: Schema.String, + token_endpoint: Schema.String, + jwks_uri: Schema.String, + revocation_endpoint: Schema.optionalKey(Schema.String), +}); +const OAuthError = Schema.Struct({ error: Schema.String }); +const decodeSessions = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Union([Sessions, Record])), +); +const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions)); +const decodeTokens = Schema.decodeUnknownSync(TokenResponse); +const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery); +const decodeOAuthError = Schema.decodeUnknownSync(OAuthError); + +export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly discoveryUrl?: string; + readonly resource?: string; + readonly defaultReturnUrl?: string; + readonly reconnectProfile?: ChatGptReconnectProfile | null; + readonly telemetryFlow?: "direct" | "primary_handoff"; +}) { + const analytics = yield* Effect.serviceOption(AnalyticsService); + const settings = yield* Effect.serviceOption(ServerSettingsService); + const track = ( + event: "auth" | "transfer", + properties: Readonly>, + task: Effect.Effect, + expiresAt?: number, + ) => + Effect.gen(function* () { + if (Option.isNone(analytics)) return yield* task; + const record = (name: string, properties: Readonly>) => + analytics.value.record(name, properties).pipe(Effect.ignoreCause); + const startedAt = yield* Clock.currentTimeMillis; + yield* record(`chatgpt.${event}.started`, properties); + return yield* task.pipe( + Effect.onExit((result) => + Effect.gen(function* () { + const endedAt = yield* Clock.currentTimeMillis; + const error = Exit.isFailure(result) + ? Cause.findErrorOption(result.cause) + : Option.none(); + const counts = + Exit.isSuccess(result) && + (event === "transfer" || options.telemetryFlow !== "primary_handoff") + ? yield* accountCounts.pipe(Effect.catchCause(() => Effect.succeed({}))) + : {}; + yield* record(`chatgpt.${event}.completed`, { + ...counts, + ...properties, + outcome: Exit.isSuccess(result) + ? "succeeded" + : Cause.hasInterruptsOnly(result.cause) + ? expiresAt !== undefined && endedAt >= expiresAt + ? "expired" + : "cancelled" + : "failed", + durationMs: Math.max(0, endedAt - startedAt), + ...(Option.isSome(error) && isSetupError(error.value) + ? { failureStage: error.value.operation } + : {}), + }); + }), + ), + ); + }); + const http = yield* HttpClient.HttpClient; + const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId); + const registrationStore = yield* ProviderCredentialStore.make( + "codex-chatgpt-registration", + options.instanceId, + ); + const secrets = yield* ServerSecretStore; + const environmentLocks = sessionLocks.get(secrets) ?? new Map(); + sessionLocks.set(secrets, environmentLocks); + const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1)); + environmentLocks.set(store.binding.key, lock); + const withSessionLock = (task: Effect.Effect) => + withChatGptSessionLock(secrets.directory, store.binding.key, options.instanceId, task); + const environment = yield* ServerEnvironmentIdentity; + const hostId = `urn:uuid:${yield* environment.getEnvironmentId}`; + const resource = options.resource ?? RESOURCE; + const failure = (operation: string, detail: string) => + new ProviderSetupError({ instanceId: options.instanceId, operation, detail }); + let metadata: typeof Discovery.Type | undefined; + let jwks: ReturnType | undefined; + const discover = Effect.gen(function* () { + if (metadata) return metadata; + const result = yield* http.get(options.discoveryUrl ?? DISCOVERY).pipe( + Effect.flatMap((response) => + Effect.gen(function* () { + if (response.status < 200 || response.status >= 300) + return yield* failure("discover", "Could not reach ChatGPT sign-in. Try again."); + return yield* response.json; + }), + ), + Effect.flatMap(decodeDiscoveryEffect), + Effect.mapError(() => failure("discover", "Could not reach ChatGPT sign-in. Try again.")), + ); + if ( + !options.discoveryUrl && + (result.issuer !== "https://auth.openai.com" || + [ + result.authorization_endpoint, + result.token_endpoint, + result.jwks_uri, + ...(result.revocation_endpoint ? [result.revocation_endpoint] : []), + ].some((url) => new URL(url).origin !== result.issuer)) + ) + return yield* failure("discover", "ChatGPT sign-in configuration could not be verified."); + metadata = result; + jwks = createRemoteJWKSet(new URL(result.jwks_uri)); + return result; + }); + const readSessions = store.get.pipe( + Effect.mapError(() => failure("read", "Could not read the saved ChatGPT connection.")), + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeed({ activeClientId: null, sessions: [] }) + : decodeSessions(new TextDecoder().decode(bytes.value)).pipe( + Effect.map((saved) => + "sessions" in saved ? saved : { activeClientId: saved.clientId, sessions: [saved] }, + ), + Effect.mapError(() => + failure("read", "The saved ChatGPT connection is invalid. Sign in again."), + ), + ), + ), + ); + const read = readSessions.pipe( + Effect.map((saved) => + Option.fromUndefinedOr( + saved.sessions.find((session) => session.clientId === saved.activeClientId), + ), + ), + ); + const writeSessions = (saved: typeof Sessions.Type) => + encodeSessions(saved).pipe( + Effect.flatMap((json) => store.set(new TextEncoder().encode(json))), + Effect.mapError(() => failure("save", "Could not save the ChatGPT connection.")), + ); + // Registration profiles outlive tokens, but belong only to this environment/instance. + // Accept the original single-registration record until it is next saved. + const readRegistrations = registrationStore.get.pipe( + Effect.mapError(() => + failure("registration", "Could not read the ChatGPT sign-in registration. Try again."), + ), + Effect.flatMap((bytes) => + Option.isNone(bytes) + ? Effect.succeed({ profiles: [], lastClientId: null }) + : decodeRegistration(new TextDecoder().decode(bytes.value)).pipe( + Effect.map((record) => { + const saved = + "profiles" in record + ? record + : { profiles: [record], lastClientId: record.clientId }; + const last = saved.profiles.find( + (profile) => profile.clientId === saved.lastClientId, + ); + const ordered = last + ? [last, ...saved.profiles.filter((profile) => profile.clientId !== last.clientId)] + : saved.profiles; + return { + ...saved, + profiles: ordered.map((profile) => ({ + ...profile, + connectionLabel: + profile.connectionLabel ?? `Connection ${saved.profiles.indexOf(profile) + 1}`, + })), + }; + }), + Effect.mapError(() => + failure("registration", "The saved ChatGPT sign-in registration is invalid."), + ), + ), + ), + ); + const accountCounts = Effect.gen(function* () { + const instanceIds = new Set([options.instanceId]); + if (Option.isSome(settings)) { + const current = yield* settings.value.getSettings; + // Include the legacy default instance as well as explicitly configured ones. + instanceIds.add("codex"); + for (const [id, instance] of Object.entries(current.providerInstances)) { + if (instance.driver === "codex") instanceIds.add(id); + } + } + const accounts = new Set(); + const connections = new Set(); + const savedConnections = new Set(); + let unidentifiedConnectedConnectionCount = 0; + for (const id of instanceIds) { + const registrations = yield* ProviderCredentialStore.make("codex-chatgpt-registration", id); + const registrationBytes = yield* registrations.get; + if (Option.isSome(registrationBytes)) { + const saved = yield* decodeRegistration(new TextDecoder().decode(registrationBytes.value)); + for (const profile of "profiles" in saved ? saved.profiles : [saved]) { + savedConnections.add(profile.clientId); + } + } + const credentials = yield* ProviderCredentialStore.make("codex-chatgpt", id); + const credentialBytes = yield* credentials.get; + if (Option.isNone(credentialBytes)) continue; + const saved = yield* decodeSessions(new TextDecoder().decode(credentialBytes.value)); + 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. + const email = session.email?.trim().toLowerCase(); + if (email) accounts.add(email); + else unidentifiedConnectedConnectionCount++; + } + } + return { + accountCountScope: Option.isSome(settings) ? "environment" : "provider_instance", + connectedAccountCount: accounts.size, + connectedConnectionCount: connections.size, + savedConnectionCount: savedConnections.size, + unidentifiedConnectedConnectionCount, + }; + }).pipe(Effect.provideService(ServerSecretStore, secrets)); + const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) { + const saved = yield* readRegistrations; + profile = { + ...profile, + connectionLabel: + saved.profiles.find((entry) => entry.clientId === profile.clientId)?.connectionLabel ?? + profile.connectionLabel ?? + `Connection ${saved.profiles.length + 1}`, + }; + yield* encodeRegistration({ + profiles: [profile, ...saved.profiles.filter((entry) => entry.clientId !== profile.clientId)], + lastClientId: profile.clientId, + }).pipe( + Effect.flatMap((json) => registrationStore.set(new TextEncoder().encode(json))), + Effect.mapError(() => + failure("registration", "Could not save the ChatGPT sign-in registration. Try again."), + ), + ); + }); + // The active pointer and all profile token sets change in one protected atomic write. + const save = Effect.fnUntraced(function* (record: CodexChatGptCredentials, activate = true) { + const saved = yield* readSessions; + yield* writeSessions({ + activeClientId: activate ? record.clientId : saved.activeClientId, + sessions: [ + ...saved.sessions.filter((session) => session.clientId !== record.clientId), + record, + ], + }); + }); + const clearTokens = Effect.gen(function* () { + const saved = yield* readSessions; + const sessions = saved.sessions.filter((session) => session.clientId !== saved.activeClientId); + if (sessions.length) yield* writeSessions({ activeClientId: null, sessions }); + else + yield* store.remove.pipe( + Effect.mapError(() => + failure("disconnect", "Could not clear the ChatGPT connection. Try again."), + ), + ); + }); + const remove = Effect.gen(function* () { + // Promote legacy identity into the retained profile before deleting credentials. + const credentials = yield* read; + if (Option.isSome(credentials)) { + const { clientId, subject, email } = credentials.value; + const saved = yield* readRegistrations; + const profile = saved.profiles.find((entry) => entry.clientId === clientId); + if (profile && (profile.subject === undefined || profile.email === undefined)) + yield* saveRegistration({ ...profile, subject, email }); + } + yield* clearTokens; + }); + const exchange = Effect.fn("CodexChatGptAuth.exchange")(function* (body: URLSearchParams) { + const endpoints = yield* discover; + const response = yield* http + .execute( + HttpClientRequest.post(endpoints.token_endpoint).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bodyText(body.toString(), "application/x-www-form-urlencoded"), + ), + ) + .pipe( + Effect.flatMap((result) => + result.json.pipe( + Effect.map((raw) => ({ + ok: result.status >= 200 && result.status < 300, + status: result.status, + raw, + })), + ), + ), + Effect.mapError(() => + failure("exchange", "ChatGPT sign-in is temporarily unavailable. Try again."), + ), + ); + if (!response.ok) { + const error = yield* Effect.try({ + try: () => decodeOAuthError(response.raw).error, + catch: () => failure("exchange", "ChatGPT did not accept this sign-in. Try again."), + }); + if ( + body.get("grant_type") === "refresh_token" && + [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + ].includes(error) + ) { + yield* remove; + return yield* failure( + "refresh", + "Your ChatGPT connection expired or was disconnected. Sign in again.", + ); + } + if (error === "invalid_client") + return yield* failure( + "client", + "OpenAI rejected this app's client registration. Check the ChatGPT connection configuration.", + ); + if (body.get("grant_type") === "authorization_code" && error === "invalid_grant") + return yield* failure("code-expired", "This sign-in code expired. Start again."); + return yield* failure( + "exchange", + error === "access_denied" + ? "ChatGPT sign-in was declined. Sign in again when you are ready." + : "ChatGPT could not complete sign-in. Try again.", + ); + } + return yield* Effect.try({ + try: () => decodeTokens(response.raw), + catch: () => + failure("exchange", "ChatGPT returned an invalid token response. Sign in again."), + }); + }); + const earliest = (value: (typeof TokenResponse.Type)["earliest_refresh_at"]) => { + if (value === undefined) return null; + const time = typeof value === "number" ? value * 1000 : Date.parse(value); + return Number.isFinite(time) ? time : null; + }; + const authenticate = Effect.fn("CodexChatGptAuth.authenticate")(function* ( + method: string, + context: ProviderAuthFlowContext, + ) { + const endpoints = yield* discover; + const existing = yield* read; + const previous = Option.getOrUndefined(existing); + const registrations = yield* readRegistrations; + const changingAccount = method === "chatgpt-change-account"; + const selectedClientId = method.startsWith("chatgpt-profile:") + ? method.slice("chatgpt-profile:".length) + : (previous?.clientId ?? registrations.lastClientId); + 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. + const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true; + const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId; + const selectedTokens = (yield* readSessions).sessions.find( + (session) => session.clientId === selectedClientId, + ); + const state = NodeCrypto.randomBytes(32).toString("base64url"); + const nonce = NodeCrypto.randomBytes(32).toString("base64url"); + const verifier = NodeCrypto.randomBytes(64).toString("base64url"); + const returnUrl = + codexAuthReturnUrl(context.returnUrl) ?? codexAuthReturnUrl(options.defaultReturnUrl); + const pageNonce = NodeCrypto.randomBytes(24).toString("base64url"); + const callback = Promise.withResolvers<{ url: URL; response?: NodeHttp.ServerResponse }>(); + const clientCallback = context.callbackMode === "client"; + let used = false; + const server = clientCallback + ? undefined + : yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method !== "GET" || url.pathname !== "/auth/callback") { + response.writeHead(404).end(); + return; + } + if (used) { + response.writeHead(410).end("Sign-in is no longer active."); + return; + } + used = true; + callback.resolve({ url, response }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + catch: () => + failure("callback", "Could not start the local sign-in callback. Try again."), + }), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server?.address(); + if (!clientCallback && (!address || typeof address === "string")) + return yield* failure("callback", "Could not start the local sign-in callback."); + // Only the port may vary between attempts; token exchange uses this exact URI. + const port = + address && typeof address !== "string" ? address.port : NodeCrypto.randomInt(49_152, 65_536); + const redirectUri = `http://127.0.0.1:${port}/auth/callback`; + const idTokenHint = selectedTokens?.idToken ?? options.reconnectProfile?.idTokenHint; + const url = new URL(endpoints.authorization_endpoint); + url.search = new URLSearchParams({ + client_id: registeredClientId ?? "dynamic_agent_client", + ...(registeredClientId + ? { + ...(savedRegistration?.email ? { login_hint: savedRegistration.email } : {}), + ...(idTokenHint ? { id_token_hint: idTokenHint } : {}), + ...(savedRegistration?.sharingEnabled === false || + (selectedTokens && !selectedTokens.scopes.includes(REQUIRED_SCOPE)) + ? { prompt: "consent" } + : {}), + } + : { agent_name_hint: "T3 Code" }), + ext_agent_host_id: hostId, + response_type: "code", + redirect_uri: redirectUri, + scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", + resource, + state, + nonce, + code_challenge_method: "S256", + code_challenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"), + }).toString(); + yield* context.setInteraction( + { + type: "browser", + id: context.flowId, + url: url.toString(), + requiresConsent: false, + acceptsCallback: true, + }, + undefined, + (callbackUrl) => + Effect.try({ + try: () => { + const returned = codexCallbackUrl(callbackUrl, redirectUri, state); + if (used) throw new Error("used"); + used = true; + callback.resolve({ url: returned }); + }, + catch: () => + failure("complete", "This redirect URL does not belong to the active ChatGPT sign-in."), + }), + ); + const received = yield* Effect.tryPromise({ + try: () => callback.promise, + catch: () => failure("callback", "ChatGPT sign-in could not be completed."), + }); + const returned = received.url; + yield* Effect.gen(function* () { + if (returned.searchParams.get("state") !== state) + return yield* failure("callback", "ChatGPT sign-in could not be verified. Start again."); + if (returned.searchParams.has("error")) + return yield* failure( + "callback", + returned.searchParams.get("error") === "access_denied" + ? "ChatGPT sign-in was declined. Sign in again when you are ready." + : "ChatGPT sign-in could not be completed. Start again.", + ); + const code = returned.searchParams.get("code"); + const clientId = registeredClientId ?? returned.searchParams.get("client_id"); + if ( + !code || + !clientId || + !clientId.startsWith("oaiapp_") || + (registeredClientId && + returned.searchParams.has("client_id") && + returned.searchParams.get("client_id") !== registeredClientId) + ) + return yield* failure( + "callback", + "ChatGPT registration is incomplete. Start sign-in again.", + ); + yield* context.verifying; + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + resource, + }), + ).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (error.operation === "code-expired") { + if (!registeredClientId) + yield* withSessionLock(saveRegistration({ clientId, redirectUri })); + return yield* failure( + "exchange", + "This sign-in code expired. Reconnect the saved ChatGPT profile to start a fresh sign-in.", + ); + } + return yield* error; + }), + ), + ); + if (!tokens.id_token) + return yield* failure( + "verify", + "ChatGPT did not return a verified identity. Sign in again.", + ); + const identity = yield* Effect.tryPromise({ + try: async () => { + const { payload } = await jwtVerify(tokens.id_token!, jwks!, { + issuer: endpoints.issuer, + audience: clientId, + algorithms: ["RS256", "ES256"], + clockTolerance: 5, + }); + if (payload.nonce !== nonce || !payload.sub || !payload.exp) throw new Error("identity"); + return { + subject: payload.sub, + email: typeof payload.email === "string" ? payload.email : null, + }; + }, + catch: () => failure("verify", "ChatGPT sign-in could not be verified. Start again."), + }); + 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. + if (registeredClientId && expectedSubject && identity.subject !== expectedSubject) + return yield* failure( + "verify", + "This sign-in returned a different ChatGPT account. Use the different-account sign-in option instead. Your saved connection is unchanged.", + ); + const knownProfile = registrations.profiles.find((profile) => profile.clientId === clientId); + if ( + knownProfile && + ((knownProfile.redirectUri && + (() => { + const original = new URL(knownProfile.redirectUri); + const current = new URL(redirectUri); + return ( + original.protocol !== current.protocol || + original.hostname !== current.hostname || + original.pathname !== current.pathname + ); + })()) || + (knownProfile.subject && knownProfile.subject !== identity.subject)) + ) + return yield* failure( + "verify", + "ChatGPT returned a conflicting account registration. Start again.", + ); + if (tokens.token_type.toLowerCase() !== "bearer") + return yield* failure( + "verify", + "ChatGPT returned an unsupported connection. Sign in again.", + ); + const scopes = tokens.scope.split(/\s+/).filter(Boolean); + yield* withSessionLock( + Effect.gen(function* () { + yield* saveRegistration({ + clientId, + redirectUri, + sharingEnabled: scopes.includes(REQUIRED_SCOPE), + ...identity, + }); + yield* save( + { + clientId, + accessToken: tokens.access_token, + idToken: tokens.id_token!, + issuer: endpoints.issuer, + refreshToken: tokens.refresh_token ?? null, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes, + ...identity, + }, + scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read), + ); + }), + ); + if (!scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + previous && previous.clientId !== clientId + ? "Token sharing was not enabled for the new account. Your existing ChatGPT connection is unchanged." + : "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.", + ); + }).pipe( + Effect.onExit((result) => + Effect.promise( + () => + new Promise((resolve) => { + const response = received.response; + if (!response || response.destroyed) { + resolve(); + return; + } + response.once("close", resolve); + response + .writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "referrer-policy": "no-referrer", + "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${pageNonce}'; base-uri 'none'; frame-ancestors 'none'`, + "x-content-type-options": "nosniff", + }) + .end(codexAuthCallbackPage(Exit.isSuccess(result), returnUrl, pageNonce), resolve); + }), + ), + ), + ); + }); + 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 (!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 logout = Effect.gen(function* () { + const credentials = Option.getOrUndefined(yield* read); + let confirmed = !credentials?.refreshToken; + if (credentials?.refreshToken) { + for (let attempt = 0; attempt < 3; attempt++) { + const result = yield* Effect.gen(function* () { + const endpoints = yield* discover; + if (!endpoints.revocation_endpoint) return { confirmed: false, retry: false }; + const response = yield* http.execute( + HttpClientRequest.post(endpoints.revocation_endpoint).pipe( + HttpClientRequest.bodyText( + new URLSearchParams({ + token: credentials.refreshToken!, + token_type_hint: "refresh_token", + client_id: credentials.clientId, + }).toString(), + "application/x-www-form-urlencoded", + ), + ), + ); + return { confirmed: response.status === 200, retry: response.status >= 500 }; + }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => ({ confirmed: false, retry: true })), + ); + confirmed = result.confirmed; + if (confirmed || !result.retry || attempt === 2) break; + yield* Effect.sleep(attempt === 0 ? "250 millis" : "1 second"); + } + } + yield* remove; + return confirmed + ? undefined + : "Signed out locally. Remote revocation could not be confirmed. Disconnect the app in ChatGPT Settings."; + }); + if (options.reconnectProfile) { + const { idTokenHint: _hint, ...registration } = options.reconnectProfile; + yield* saveRegistration(registration).pipe(Effect.orDie); + } + const controller = yield* ProviderAuthFlow.make({ + instanceId: options.instanceId, + credentialBinding: store.binding, + refreshMethodsAfterAuth: true, + methods: Effect.gen(function* () { + const saved = yield* readRegistrations; + const active = Option.getOrUndefined(yield* read); + const current = saved.profiles.find( + (profile) => profile.clientId === (active?.clientId ?? saved.lastClientId), + ); + const profiles = current + ? [current, ...saved.profiles.filter((profile) => profile.clientId !== current.clientId)] + : saved.profiles; + return [ + { + id: "chatgpt", + name: "Sign in with ChatGPT", + description: current?.email ? `Reconnect ${current.email}.` : null, + ...(current?.email ? { accountEmail: current.email } : {}), + type: "agent" as const, + }, + { + id: "chatgpt-change-account", + name: "Use a different ChatGPT account", + description: "Register a connection for another ChatGPT account.", + type: "agent" as const, + }, + // The wire contract allows 32 methods; advertise the 30 most recent profiles. + ...profiles.slice(0, 30).map((profile) => ({ + id: profileMethodId(profile.clientId), + name: `${profile.email ?? "ChatGPT account"} · ${profile.connectionLabel}`, + ...(profile.email ? { accountEmail: profile.email } : {}), + description: "Reuse this account's original sign-in registration.", + type: "agent" as const, + })), + ]; + }), + authenticate: (method, context) => + lock.withPermit( + track( + "auth", + { + flow: options.telemetryFlow ?? "direct", + intent: + options.telemetryFlow === "primary_handoff" + ? options.reconnectProfile + ? "saved_profile" + : "different_account" + : method === "chatgpt-change-account" + ? "different_account" + : method.startsWith("chatgpt-profile:") + ? "saved_profile" + : "default", + callbackMode: context.callbackMode ?? "server", + }, + authenticate(method, context), + context.expiresAt, + ), + ), + logout: lock.withPermit(withSessionLock(logout)), + }); + const reconnectProfile = Effect.fnUntraced(function* (methodId: string) { + if (methodId === "chatgpt-change-account") return null; + const registrations = yield* readRegistrations; + const active = Option.getOrUndefined(yield* read); + const clientId = methodId.startsWith("chatgpt-profile:") + ? methodId.slice("chatgpt-profile:".length) + : (active?.clientId ?? registrations.lastClientId); + const registration = registrations.profiles.find((profile) => profile.clientId === clientId); + if (!registration) { + if (methodId.startsWith("chatgpt-profile:")) + return yield* failure("export", "This saved connection is no longer available."); + return null; + } + const session = (yield* readSessions).sessions.find((session) => session.clientId === clientId); + return { ...registration, ...(session?.idToken ? { idTokenHint: session.idToken } : {}) }; + }); + const exportProfile = Effect.gen(function* () { + const credentials = Option.getOrUndefined(yield* read); + const registration = + credentials && + (yield* readRegistrations).profiles.find( + (profile) => profile.clientId === credentials.clientId, + ); + if (!credentials?.idToken || !credentials.issuer || !registration) + return yield* failure( + "export", + "Complete ChatGPT sign-in before transferring this connection.", + ); + return { + registration, + credentials: { ...credentials, idToken: credentials.idToken, issuer: credentials.issuer }, + } satisfies ChatGptTransferredProfile; + }); + const importProfile = ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => { + const validate = Effect.gen(function* () { + const endpoints = yield* discover; + const credentials = profile.credentials; + if ( + credentials.clientId !== profile.registration.clientId || + credentials.issuer !== endpoints.issuer || + !credentials.scopes.includes(REQUIRED_SCOPE) || + credentials.expiresAt <= (yield* Clock.currentTimeMillis) + ) + return yield* failure( + "import", + "The transferred ChatGPT connection is invalid or expired.", + ); + const identity = yield* Effect.tryPromise({ + try: () => + jwtVerify(credentials.idToken, jwks!, { + issuer: endpoints.issuer, + audience: credentials.clientId, + algorithms: ["RS256", "ES256"], + clockTolerance: 5, + }), + catch: () => failure("import", "The transferred ChatGPT identity could not be verified."), + }); + if ( + identity.payload.sub !== credentials.subject || + profile.registration.subject !== credentials.subject || + (identity.payload.email ?? null) !== credentials.email || + (profile.registration.email !== undefined && + profile.registration.email !== credentials.email) + ) + return yield* failure( + "import", + "The transferred ChatGPT identity does not match its profile.", + ); + }); + const saveImported = Effect.gen(function* () { + const { idTokenHint: _hint, ...registration } = profile.registration; + const existing = (yield* readRegistrations).profiles.find( + (saved) => saved.clientId === registration.clientId, + ); + if (existing?.subject && existing.subject !== profile.credentials.subject) + return yield* failure( + "import", + "The transferred identity conflicts with the saved ChatGPT connection.", + ); + yield* saveRegistration(registration); + yield* save(profile.credentials, true); + }); + return lock.withPermit( + track( + "transfer", + { flow: "primary_handoff" }, + validate.pipe( + Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)), + ), + ), + ); + }; + return { + controller: { ...controller, reconnectProfile, importProfile }, + read, + access, + exportProfile, + revoke: lock.withPermit(withSessionLock(remove)), + }; +}); diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts new file mode 100644 index 000000000000..2a1dce4a6a96 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -0,0 +1,63 @@ +import type { ChatGptHandoffInput, ChatGptHandoffState } from "@t3tools/contracts"; +import { ProviderSetupError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/unstable/http"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; + +// The primary owns OAuth for this stream; the destination owns the refresh session. +export function subscribeChatGptHandoff( + input: ChatGptHandoffInput, + owner: string, + endpoints: { discoveryUrl: string; resource: string } | undefined = undefined, +) { + return Stream.unwrap( + Effect.gen(function* () { + const bytes = new Map(); + yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear())); + const store = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), + set: (key, value) => + Effect.sync(() => { + bytes.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + bytes.delete(key); + }), + create: () => Effect.die("Handoff does not create persistent secrets."), + getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."), + }); + const auth = yield* makeCodexChatGptAuth({ + ...endpoints, + instanceId: input.instanceId, + reconnectProfile: input.profile, + telemetryFlow: "primary_handoff", + defaultReturnUrl: input.returnUrl, + }).pipe( + Effect.provideService(ServerSecretStore, store), + Effect.provideService( + ServerEnvironmentIdentity, + ServerEnvironmentIdentity.of({ + getEnvironmentId: Effect.succeed(input.environmentId), + }), + ), + Effect.provide(FetchHttpClient.layer), + ); + yield* auth.controller.start(owner, Effect.void, "chatgpt", input.returnUrl, "server"); + return auth.controller.subscribe(owner).pipe( + Stream.takeUntil((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.mapEffect((state): Effect.Effect => + state.phase === "succeeded" + ? auth.exportProfile.pipe( + Effect.map((profile) => ({ phase: "finished" as const, profile })), + ) + : Effect.succeed({ phase: "auth" as const, state }), + ), + ); + }), + ); +} diff --git a/apps/server/src/provider/CodexChatGptModels.test.ts b/apps/server/src/provider/CodexChatGptModels.test.ts new file mode 100644 index 000000000000..9e7ec690d1df --- /dev/null +++ b/apps/server/src/provider/CodexChatGptModels.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics preferSchemaOverJson:off - Mock HTTP responses use JSON fixtures. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { chatGptModels } from "./CodexChatGptModels.ts"; + +it.effect( + "uses each selected profile's token and the server's visible catalog order and names", + () => + Effect.gen(function* () { + const requests: string[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + assert.strictEqual(request.url, "https://api.openai.com/v1/models"); + requests.push(request.headers.authorization!); + return HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + models: + request.headers.authorization === "Bearer account-a" + ? [ + { slug: "second", display_name: "Second from OpenAI", visibility: "list" }, + { slug: "hidden", display_name: "Hidden", visibility: "hidden" }, + { slug: "first", display_name: "First from OpenAI", visibility: "list" }, + ] + : [{ slug: "account-b-only", display_name: "B", visibility: "list" }], + }), + ), + ); + }), + ); + const native = [ + { + slug: "first", + name: "Cached first", + isCustom: false, + capabilities: { optionDescriptors: [] }, + }, + { slug: "not-entitled", name: "Cached other", isCustom: false, capabilities: null }, + ]; + const a = yield* chatGptModels("account-a", native).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + assert.deepEqual( + a.map((model) => [model.slug, model.name]), + [ + ["second", "Second from OpenAI"], + ["first", "First from OpenAI"], + ], + ); + assert.deepEqual(a[1]!.capabilities, native[0]!.capabilities); + assert.isNull(a[0]!.capabilities); + const b = yield* chatGptModels("account-b", native).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + assert.deepEqual( + b.map((model) => model.slug), + ["account-b-only"], + ); + assert.deepEqual(requests, ["Bearer account-a", "Bearer account-b"]); + }), +); + +it.effect("does not present a cached catalog as account entitlements when discovery fails", () => + Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }))), + ); + const error = yield* Effect.flip( + chatGptModels("account-a", []).pipe(Effect.provideService(HttpClient.HttpClient, http)), + ); + assert.strictEqual(error._tag, "ChatGptCatalogError"); + }), +); diff --git a/apps/server/src/provider/CodexChatGptModels.ts b/apps/server/src/provider/CodexChatGptModels.ts new file mode 100644 index 000000000000..5b00d1d82886 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptModels.ts @@ -0,0 +1,47 @@ +import type { ServerProviderModel } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +export class ChatGptCatalogError extends Schema.TaggedError()( + "ChatGptCatalogError", + { status: Schema.Int }, +) {} + +const Catalog = Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + slug: Schema.NonEmptyString, + display_name: Schema.NonEmptyString, + visibility: Schema.String, + }), + ), +}); + +/** Account choices come from OpenAI; native model/list contributes capability metadata only. */ +export const chatGptModels = Effect.fn("chatGptModels")(function* ( + accessToken: string, + nativeModels: ReadonlyArray, +) { + const http = yield* HttpClient.HttpClient; + const response = yield* http.execute( + HttpClientRequest.get("https://api.openai.com/v1/models").pipe( + HttpClientRequest.bearerToken(accessToken), + ), + ); + if (response.status !== 200) return yield* new ChatGptCatalogError({ status: response.status }); + const catalog = yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Catalog))); + return catalog.models + .filter((model) => model.visibility === "list") + .map( + (model) => + ({ + ...nativeModels.find((native) => native.slug === model.slug), + capabilities: + nativeModels.find((native) => native.slug === model.slug)?.capabilities ?? null, + slug: model.slug, + name: model.display_name, + isCustom: false, + }) satisfies ServerProviderModel, + ); +}, Effect.timeout("15 seconds")); diff --git a/apps/server/src/provider/CodexChatGptSessionLock.test.ts b/apps/server/src/provider/CodexChatGptSessionLock.test.ts new file mode 100644 index 000000000000..b8bdd3a0e976 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptSessionLock.test.ts @@ -0,0 +1,75 @@ +// @effect-diagnostics nodeBuiltinImport:off - A separate Node process proves filesystem exclusion. +import * as NodeChildProcess from "node:child_process"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +const lockModule = NodeModule.createRequire(import.meta.url).resolve("proper-lockfile"); +const instanceId = ProviderInstanceId.make("cross-process-test"); +const probe = (directory: string) => + Effect.promise(async () => { + const { stdout } = await execFile(process.execPath, [ + "-e", + ` + const { lock } = require(process.argv[1]); + lock(process.argv[2], { realpath: false }).then(async release => { + await release(); process.stdout.write('acquired'); + }, error => { + if (error.code === 'ELOCKED') process.stdout.write('blocked'); + else { console.error(error); process.exitCode = 1; } + }); + `, + lockModule, + NodePath.join(directory, "session.bin"), + ]); + return stdout; + }); + +it.live("excludes another process and releases after the credential update", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const blocked = yield* withChatGptSessionLock( + directory, + "session", + instanceId, + probe(directory), + ); + assert.strictEqual(blocked, "blocked"); + assert.strictEqual(yield* probe(directory), "acquired"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("releases the cross-process lease when the operation is interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const entered = yield* Deferred.make(); + const operation = yield* withChatGptSessionLock( + directory, + "session", + instanceId, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(entered); + assert.strictEqual(yield* probe(directory), "blocked"); + yield* Fiber.interrupt(operation); + assert.strictEqual(yield* probe(directory), "acquired"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/CodexChatGptSessionLock.ts b/apps/server/src/provider/CodexChatGptSessionLock.ts new file mode 100644 index 000000000000..e580f2a78eb2 --- /dev/null +++ b/apps/server/src/provider/CodexChatGptSessionLock.ts @@ -0,0 +1,45 @@ +// @effect-diagnostics nodeBuiltinImport:off - Credential leases coordinate Node server processes. +import * as NodePath from "node:path"; +import { lock } from "proper-lockfile"; +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +/** Keep rotating tokens and their active profile atomic across servers sharing a secret store. */ +export const withChatGptSessionLock = ( + directory: string | undefined, + key: string, + instanceId: ProviderInstanceId, + task: Effect.Effect, +): Effect.Effect => { + // In-memory stores have no shared filesystem; the auth controller still serializes its callers. + if (!directory) return task; + const failure = () => + new ProviderSetupError({ + instanceId, + operation: "credential-lock", + detail: "Could not lock the ChatGPT connection for an update. Try again.", + }); + return Effect.scoped( + Effect.gen(function* () { + const compromised = yield* Deferred.make(); + const services = yield* Effect.context(); + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + lock(NodePath.join(directory, `${key}.bin`), { + realpath: false, + stale: 120_000, + update: 10_000, + retries: { retries: 80, factor: 1, minTimeout: 500, maxTimeout: 500 }, + onCompromised: () => + Effect.runSyncWith(services)(Deferred.fail(compromised, failure())), + }), + catch: failure, + }), + (release) => Effect.promise(() => release()).pipe(Effect.ignore), + ); + return yield* Effect.raceFirst(task, Deferred.await(compromised)); + }), + ); +}; diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 7ec7b58c0bd5..d74795480566 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,23 +1,18 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; +import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; -const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` - -## T3 Turbo collaborative browser +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Turbo collaborative browser You are running inside T3 Turbo. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. -`; - -const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.`; -## T3 Code devices +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, which is on PATH. 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. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route. -`; +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, which is on PATH. 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.`; export interface T3CodeToolAvailability { readonly browser: boolean; @@ -36,16 +31,17 @@ const normalizeAvailability = ( * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would * talk it out of the only automation it still has. */ -const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => { +const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { const tools = normalizeAvailability(availability); - return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${ - tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "" - }`; + return [ + tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "", + tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "", + ] + .filter(Boolean) + .join("\n\n"); }; -const codexPlanModeDeveloperInstructions = ( - browserToolsAvailable: boolean | T3CodeToolAvailability, -): string => `# Plan Mode (Conversational) +const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -173,12 +169,9 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. If the user stays in Plan mode and asks for revisions after a prior \`\`, any new \`\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`\` unchanged. -${browserToolInstructions(browserToolsAvailable)} `; -const codexDefaultModeDeveloperInstructions = ( - browserToolsAvailable: boolean | T3CodeToolAvailability, -): string => `# Collaboration Mode: Default +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. @@ -189,29 +182,46 @@ Your active mode changes only when new developer instructions with a different \ Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -${browserToolInstructions(browserToolsAvailable)} `; export interface CodexRuntimeInfo { readonly model: string; + readonly modelName?: string | undefined; readonly reasoningEffort: string; } -export function buildCodexDeveloperInstructions( - interactionMode: ProviderInteractionMode, +/** Mode prompt for `turn/start.collaborationMode.settings.developer_instructions`. */ +export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { + return interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; +} + +/** + * T3 Code context for `turn/start.additionalContext`. Codex renders each entry + * as a `value` developer message and resends it only when the value + * changes. + * + * 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. + */ +export function buildCodexAdditionalContext( runtime: CodexRuntimeInfo, /** * Whether the `t3-code` MCP server is attached to this turn. Callers derive * it from the session's actual MCP configuration rather than re-reading the * setting, so the prompt cannot claim tools the turn doesn't have. */ - browserToolsAvailable: boolean | T3CodeToolAvailability = true, -): string { - const base = - interactionMode === "plan" - ? codexPlanModeDeveloperInstructions(browserToolsAvailable) - : codexDefaultModeDeveloperInstructions(browserToolsAvailable); - return `${base} - -${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`; + toolsAvailable: boolean | T3CodeToolAvailability = true, +): Record { + const tools = toolInstructions(toolsAvailable); + // Separate keys keep each value under Codex's per-entry token cap. + return { + t3_code_runtime: { + kind: "application", + value: buildRuntimeInstructions({ harness: "Codex", ...runtime }), + }, + ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), + }; } diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts new file mode 100644 index 000000000000..906d04bab699 --- /dev/null +++ b/apps/server/src/provider/CodexInstallation.test.ts @@ -0,0 +1,366 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { BUNDLED_MODEL_MANIFEST, ModelManifest, type ModelManifestData } from "./ModelManifest.ts"; +import { expect, it } from "@effect/vitest"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import * as NodeCrypto from "node:crypto"; +import { + makeCodexInstallation, + type CodexInstallation, + type CodexInstallationOptions, + resolveCodexReleaseAsset, +} from "./CodexInstallation.ts"; + +const archive = Buffer.from( + "H4sIAAAAAAAC/+3W0W6DIBQGYB/FcD0sKNSkD7J7aom6tmAQtzXL3n3QZM3qdWVt+n8XoCckJp78wLY3q8bu9Ge2HBbUUp7nYD4H8s9zrNdSsCxnWQLT6JULn8ye09K9h/u2/c0/jSM9xqGzo0+bf3Gdf15WQiD/Kdy61/CA+z8dlO9Wrv2387+c5Z9VZY38p7BY0+Gh8t/sVauLt9Ga9Pnnop7ln1e4/6fxRQ7qZCf/qt3YW0M2OX/JyfvljbCCy3XBSaiGH9VqH4tKuaZbC6qG4aDpTrmP3sQV2nh3Gmxvzqsud0vyjaABAAAAAAAAAAAAAAAk8gOq19rvACgAAA==", + "base64", +); +const asset = { + version: "0.156.1", + target: "aarch64-apple-darwin", + url: "https://github.com/openai/codex/releases/download/test/package.tar.gz", + sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"), + archiveBytes: archive.length, +}; +const makeHarness = Effect.fn("test.makeCodexInstallation")(function* ( + input: { + options?: Partial; + manifestCurrent?: Effect.Effect; + body?: Stream.Stream; + baseDir?: string; + local?: { version: string; appServerFails?: boolean; versionFails?: boolean }; + } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const baseDir = + input.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-install-test-" })); + const localDirectory = `${baseDir}/local`; + const localBinaryPath = `${localDirectory}/codex`; + const probeLog = `${baseDir}/local-probes.txt`; + if (input.local) { + yield* fs.makeDirectory(localDirectory, { recursive: true }); + yield* fs.writeFileString( + localBinaryPath, + `#!/bin/sh\nprintf '%s\\n' "$*" >> '${probeLog}'\ncase "$1" in\n--version) ${input.local.versionFails ? "exit 1" : `printf '%s\\n' 'codex-cli ${input.local.version}'`};;\napp-server) exit ${input.local.appServerFails ? "1" : "0"};;\nesac\n`, + { mode: 0o755 }, + ); + } + let downloads = 0; + const installation = yield* makeCodexInstallation({ + baseDir, + releaseAsset: asset, + validate: () => Effect.void, + ...input.options, + }).pipe( + Effect.provideService(ModelManifest, { + current: input.manifestCurrent ?? Effect.succeed(BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, + }), + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcessArchitecture, "arm64"), + Effect.provideService(HostProcessEnvironment, { PATH: input.local ? localDirectory : "" }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + downloads++; + return Object.defineProperty( + HttpClientResponse.fromWeb(request, new Response(null)), + "stream", + { + value: input.body ?? Stream.succeed(archive), + }, + ); + }), + ), + ), + ); + return { installation, fs, baseDir, localBinaryPath, probeLog, downloads: () => downloads }; +}); +const terminalState = (installation: CodexInstallation["Service"]) => + installation.changes.pipe( + Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +for (const version of ["0.156.0", "0.156.1", "0.156.2", "0.157.0"]) { + it.effect(`reuses installed Codex ${version} without downloading or taking ownership of it`, () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version } }); + expect(yield* h.installation.start).toMatchObject({ + source: "local", + phase: "succeeded", + installedVersion: version, + executablePath: h.localBinaryPath, + canRemove: false, + }); + expect(yield* h.installation.resolve()).toMatchObject({ + source: "local", + executablePath: h.localBinaryPath, + managedVersionDirectory: null, + version, + }); + yield* h.installation.acquire().pipe(Effect.scoped); + expect(h.downloads()).toBe(0); + expect(yield* h.fs.exists(h.installation.managedDirectory)).toBe(false); + expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([ + "--version", + "app-server --help", + ]); + yield* h.installation.remove(); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + expect((yield* h.installation.state).source).toBe("local"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} +for (const local of [ + { version: "0.128.9" }, + { version: "0.145.0" }, + { version: "0.155.1" }, + { version: "0.155.9" }, + { version: "0.156.1-alpha.1" }, + { version: "unknown" }, + { version: "0.156.1", appServerFails: true }, + { version: "0.156.1", versionFails: true }, +]) { + it.effect( + `downloads the pinned release when the local CLI is unsupported or broken: ${JSON.stringify(local)}`, + () => + Effect.gen(function* () { + const h = yield* makeHarness({ local }); + expect((yield* h.installation.state).installedVersion).toBeNull(); + yield* h.installation.start; + const installed = yield* terminalState(h.installation); + expect(installed.phase).toBe("succeeded"); + const executable = yield* h.installation.resolve(); + expect(executable.source).toBe("managed"); + expect(installed.executablePath).toBe(executable.executablePath); + expect(h.downloads()).toBe(1); + expect(yield* h.fs.exists(h.localBinaryPath)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} +it.effect("falls back to a managed download when the reused local executable disappears", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + expect((yield* h.installation.resolve()).source).toBe("local"); + yield* h.fs.remove(h.localBinaryPath); + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rechecks compatibility after the local executable is replaced", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + expect((yield* h.installation.resolve()).source).toBe("local"); + yield* h.fs.remove(h.localBinaryPath); + yield* h.fs.writeFileString(h.localBinaryPath, "#!/bin/sh\nprintf 'codex-cli 0.128.9\\n'\n", { + mode: 0o755, + }); + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rechecks a cached local executable when the shared manifest policy changes", () => + Effect.gen(function* () { + let manifest = BUNDLED_MODEL_MANIFEST; + const h = yield* makeHarness({ + local: { version: "0.156.0" }, + manifestCurrent: Effect.sync(() => manifest), + }); + expect((yield* h.installation.resolve()).source).toBe("local"); + manifest = { + ...manifest, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + ranges: [ + { range: ">=0.156.1", status: "supported" }, + { range: "<0.156.1", status: "broken" }, + ], + }, + ], + }; + yield* h.installation.start; + expect((yield* terminalState(h.installation)).phase).toBe("succeeded"); + expect((yield* h.installation.resolve()).source).toBe("managed"); + expect(h.downloads()).toBe(1); + expect((yield* h.fs.readFileString(h.probeLog)).trim().split("\n")).toEqual([ + "--version", + "app-server --help", + ]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("uses bundled Codex compatibility when the remote manifest omits its policy", () => + Effect.gen(function* () { + const h = yield* makeHarness({ + local: { version: "0.156.0" }, + manifestCurrent: Effect.succeed({ ...BUNDLED_MODEL_MANIFEST, compatibility: [] }), + }); + expect((yield* h.installation.start).source).toBe("local"); + expect(h.downloads()).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("preserves the invoked name of version-manager launcher symlinks", () => + Effect.gen(function* () { + const h = yield* makeHarness({ local: { version: "0.156.1" } }); + const launcher = `${h.baseDir}/launcher`; + yield* h.fs.writeFileString( + launcher, + '#!/bin/sh\ncase "$0" in */codex) ;; *) exit 1;; esac\ncase "$1" in --version) printf "codex-cli 0.156.1\\n";; app-server) exit 0;; esac\n', + { mode: 0o755 }, + ); + yield* h.fs.remove(h.localBinaryPath); + yield* h.fs.symlink(launcher, h.localBinaryPath); + expect(yield* h.installation.start).toMatchObject({ source: "local", phase: "succeeded" }); + expect((yield* h.installation.resolve()).executablePath).toBe(h.localBinaryPath); + expect(h.downloads()).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "installs the complete verified package in tools/codex/version and survives a restart", + () => + Effect.gen(function* () { + const { installation, fs, baseDir } = yield* makeHarness(); + yield* installation.start; + expect((yield* terminalState(installation)).phase).toBe("succeeded"); + const executable = yield* installation.resolve(); + expect(executable.executablePath).toBe(`${baseDir}/tools/codex/0.156.1/bin/codex`); + expect( + yield* fs.readFileString(`${executable.managedVersionDirectory}/bin/codex-code-mode-host`), + ).toBe("host"); + expect(yield* fs.readFileString(`${executable.managedVersionDirectory}/codex-path/rg`)).toBe( + "rg", + ); + const restarted = yield* makeHarness({ baseDir }); + expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("requires an update before running an older activated managed installation", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + yield* first.installation.start; + yield* terminalState(first.installation); + const executable = yield* first.installation.resolve(); + const oldDirectory = `${first.installation.managedDirectory}/0.155.1`; + yield* first.fs.rename(executable.managedVersionDirectory!, oldDirectory); + for (const name of ["codex-package.json", ".install-complete.json"]) { + const file = `${oldDirectory}/${name}`; + yield* first.fs.writeFileString( + file, + (yield* first.fs.readFileString(file)).replaceAll("0.156.1", "0.155.1"), + ); + } + yield* first.fs.writeFileString( + `${first.installation.managedDirectory}/active.json`, + '{"version":"0.155.1"}', + ); + const restarted = yield* makeHarness({ baseDir: first.baseDir }); + expect((yield* Effect.flip(restarted.installation.resolve())).detail).toContain( + "outside the supported range", + ); + yield* restarted.installation.start; + expect((yield* terminalState(restarted.installation)).phase).toBe("succeeded"); + expect((yield* restarted.installation.resolve()).version).toBe("0.156.1"); + expect(restarted.downloads()).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects corrupted downloads without publishing a runtime", () => + Effect.gen(function* () { + const { installation, fs } = yield* makeHarness({ + options: { releaseAsset: { ...asset, sha256: "0".repeat(64) } }, + }); + yield* installation.start; + const state = yield* terminalState(installation); + expect(state.phase).toBe("failed"); + expect(state.message).toContain("SHA-256"); + expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); + expect(yield* fs.exists(`${installation.managedDirectory}/0.156.1`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancels an in-flight download and cleans the staging directory", () => + Effect.gen(function* () { + const downloading = yield* Deferred.make(); + const body = Stream.fromEffect( + Deferred.succeed(downloading, undefined).pipe(Effect.andThen(Effect.never)), + ); + const { installation, fs } = yield* makeHarness({ body }); + const started = yield* installation.start; + yield* Deferred.await(downloading); + expect((yield* installation.cancel(started.operationId!)).phase).toBe("cancelled"); + expect( + (yield* fs.readDirectory(installation.managedDirectory)).filter((name) => + name.startsWith(".install-"), + ), + ).toEqual([]); + expect(yield* fs.exists(`${installation.managedDirectory}/active.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("protects active process leases and removes the runtime after release", () => + Effect.gen(function* () { + const { installation, fs } = yield* makeHarness(); + yield* installation.start; + yield* terminalState(installation); + const leaseScope = yield* Scope.make(); + yield* installation.acquire().pipe(Effect.provideService(Scope.Scope, leaseScope)); + expect((yield* Effect.flip(installation.remove())).detail).toContain("Stop Codex sessions"); + yield* Scope.close(leaseScope, Exit.void); + yield* installation.remove(); + expect(yield* fs.exists(installation.managedDirectory)).toBe(false); + expect((yield* installation.state).installedVersion).toBeNull(); + expect((yield* installation.state).executablePath).toBeNull(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps an activated runtime when a later update fails verification", () => + Effect.gen(function* () { + const first = yield* makeHarness(); + yield* first.installation.start; + yield* terminalState(first.installation); + const update = yield* makeHarness({ + baseDir: first.baseDir, + options: { releaseAsset: { ...asset, version: "0.156.2", sha256: "0".repeat(64) } }, + }); + expect((yield* update.installation.state).executablePath).toBe( + (yield* first.installation.resolve()).executablePath, + ); + yield* update.installation.start; + expect((yield* terminalState(update.installation)).phase).toBe("failed"); + expect((yield* update.installation.resolve()).version).toBe("0.156.1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it("publishes complete packages for all supported platforms", () => { + for (const platform of ["darwin", "linux", "win32"] as const) + for (const arch of ["x64", "arm64"]) + expect(resolveCodexReleaseAsset(platform, arch)?.url).toContain("codex-package-"); + expect(resolveCodexReleaseAsset("linux", "riscv64")).toBeNull(); +}); diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts new file mode 100644 index 000000000000..4beec81770a6 --- /dev/null +++ b/apps/server/src/provider/CodexInstallation.ts @@ -0,0 +1,734 @@ +// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest. +import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; +import { + HostProcessArchitecture, + HostProcessEnvironment, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeCrypto from "node:crypto"; +import { ServerConfig } from "../config.ts"; +import { BUNDLED_MODEL_MANIFEST, ModelManifest } from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; + +const DRIVER = ProviderDriverKind.make("codex"); +const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u)); +const ActiveRelease = Schema.Struct({ version: Version }); +const InstalledRelease = Schema.Struct({ + version: Version, + target: Schema.String, + sha256: Schema.String, +}); +const PackageManifest = Schema.Struct({ + layoutVersion: Schema.Literal(1), + version: Version, + target: Schema.String, + entrypoint: Schema.String, +}); +const decodeVersion = Schema.decodeUnknownEffect(Version); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); +const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); +export interface CodexReleaseAsset { + readonly version: string; + readonly target: string; + readonly url: string; + readonly sha256: string; + readonly archiveBytes: number; +} +// Official complete packages retain the code-mode, search, and resource companions. +const RELEASES: Readonly> = { + "darwin-arm64": { + version: "0.156.1", + target: "aarch64-apple-darwin", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-apple-darwin.tar.gz", + sha256: "fea42f9625091f011e38f059da974d52e57ba31831648bb1c7f0b1a385fde547", + archiveBytes: 127394863, + }, + "darwin-x64": { + version: "0.156.1", + target: "x86_64-apple-darwin", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-apple-darwin.tar.gz", + sha256: "618dbcd55419fa041871f777a14b107ceb3fe2d339ef81e21e6ab5374420dc71", + archiveBytes: 138670455, + }, + "linux-arm64": { + version: "0.156.1", + target: "aarch64-unknown-linux-musl", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-unknown-linux-musl.tar.gz", + sha256: "fdd47ed6aade0360796fd3f6f95a45096f327c15e19e8c7339f9dc5633041786", + archiveBytes: 136933361, + }, + "linux-x64": { + version: "0.156.1", + target: "x86_64-unknown-linux-musl", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-unknown-linux-musl.tar.gz", + sha256: "8b711520beddf385467b8da4d2c93736637c6ba1e46811cf0d8606b7c490b6f6", + archiveBytes: 145976992, + }, + "win32-arm64": { + version: "0.156.1", + target: "aarch64-pc-windows-msvc", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-aarch64-pc-windows-msvc.tar.gz", + sha256: "85994caecdc7609c49fd585c1cdb5677fa9d0acbf789650cff23a13afc9505db", + archiveBytes: 142037952, + }, + "win32-x64": { + version: "0.156.1", + target: "x86_64-pc-windows-msvc", + url: "https://github.com/openai/codex/releases/download/rust-v0.156.1/codex-package-x86_64-pc-windows-msvc.tar.gz", + sha256: "a2e017db9807e6a2269a26fea0e1d9546469cef4d472a33016bc9f3ad7d3b733", + archiveBytes: 153839991, + }, +}; +export const resolveCodexReleaseAsset = (platform: NodeJS.Platform, arch: string) => + RELEASES[`${platform}-${arch}`] ?? null; +export class CodexInstallationError extends Schema.TaggedError()( + "CodexInstallationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message() { + return this.detail; + } +} +const isInstallationError = Schema.is(CodexInstallationError); +const installationError = (operation: string, detail: string, cause?: unknown) => + new CodexInstallationError({ operation, detail, ...(cause === undefined ? {} : { cause }) }); +const wrapFailure = (operation: string, detail: string) => (cause: unknown) => + isInstallationError(cause) ? cause : installationError(operation, detail, cause); +export interface CodexExecutable { + readonly executablePath: string; + readonly source: "managed" | "local"; + readonly version: string; + readonly managedVersionDirectory: string | null; +} +interface CodexInstallationService { + readonly managedDirectory: string; + readonly resolve: () => Effect.Effect; + readonly acquire: () => Effect.Effect; + readonly start: Effect.Effect; + readonly cancel: ( + operationId: string, + ) => Effect.Effect; + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + readonly remove: ( + protectedBinaryPaths?: ReadonlyArray, + ) => Effect.Effect; +} +export class CodexInstallation extends Context.Service< + CodexInstallation, + CodexInstallationService +>()("t3/provider/CodexInstallation") { + static readonly layer = Layer.effect( + CodexInstallation, + Effect.gen(function* () { + const config = yield* ServerConfig; + return yield* makeCodexInstallation({ baseDir: config.baseDir }); + }), + ); +} +export interface CodexInstallationOptions { + readonly baseDir: string; + readonly releaseAsset?: CodexReleaseAsset | null; + readonly validate?: ( + executable: CodexExecutable, + expectedVersion: string, + ) => Effect.Effect; +} +const isRunning = (state: ProviderInstallState) => + ["downloading", "extracting", "verifying"].includes(state.phase); +export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function* ( + options: CodexInstallationOptions, +) { + const manifestService = yield* ModelManifest; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serviceScope = yield* Effect.scope; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const arch = yield* HostProcessArchitecture; + const asset = + options.releaseAsset === undefined + ? resolveCodexReleaseAsset(platform, arch) + : options.releaseAsset; + const managedDirectory = path.join(options.baseDir, "tools", "codex"); + const activePath = path.join(managedDirectory, "active.json"); + const executableName = platform === "win32" ? "codex.exe" : "codex"; + const gate = yield* Semaphore.make(1); + let leases = 0; + let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined; + const state = yield* SubscriptionRef.make({ + driver: DRIVER, + operationId: null, + phase: "idle", + downloadedBytes: 0, + totalBytes: asset?.archiveBytes ?? null, + version: asset?.version ?? null, + installedVersion: null, + executablePath: null, + canRemove: false, + message: null, + }); + const readRecord = Effect.fn("CodexInstallation.readRecord")(function* ( + file: string, + schema: Schema.Codec, + ) { + const info = yield* fs.stat(file); + if (info.type !== "File" || Number(info.size) > 8192) + return yield* installationError( + "resolve", + "The managed Codex installation record is invalid. Reinstall Codex.", + ); + return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + yield* fs.readFileString(file), + ); + }); + const fromDirectory = Effect.fn("CodexInstallation.fromDirectory")(function* ( + directory: string, + version: string, + target: string, + ) { + const manifest = yield* readRecord(path.join(directory, "codex-package.json"), PackageManifest); + if ( + manifest.version !== version || + manifest.target !== target || + manifest.entrypoint !== `bin/${executableName}` + ) + return yield* installationError( + "verify", + "The downloaded Codex package does not match the expected release.", + ); + for (const name of [ + `bin/${executableName}`, + `bin/codex-code-mode-host${platform === "win32" ? ".exe" : ""}`, + `codex-path/rg${platform === "win32" ? ".exe" : ""}`, + ]) { + const info = yield* fs.stat(path.join(directory, name)); + if ( + info.type !== "File" || + Number(info.size) === 0 || + (platform !== "win32" && (info.mode & 0o111) === 0) + ) + return yield* installationError( + "verify", + "The managed Codex package is incomplete. Reinstall Codex.", + ); + } + return { + executablePath: path.join(directory, "bin", executableName), + source: "managed", + version, + managedVersionDirectory: directory, + } satisfies CodexExecutable; + }); + const completedRelease = Effect.fn("CodexInstallation.completedRelease")(function* ( + version: string, + ) { + yield* decodeVersion(version); + const directory = path.join(managedDirectory, version); + const record = yield* readRecord( + path.join(directory, ".install-complete.json"), + InstalledRelease, + ); + if (record.version !== version) + return yield* installationError( + "resolve", + "The managed Codex installation record has the wrong version.", + ); + return yield* fromDirectory(directory, version, record.target); + }); + const compatibility = Effect.fn("CodexInstallation.compatibility")(function* (version: string) { + const manifest = yield* manifestService.current; + return ( + resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ?? + resolveProviderCompatibility(BUNDLED_MODEL_MANIFEST.compatibility, DRIVER, version) + ); + }); + const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")( + function* () { + const active = yield* readRecord(activePath, ActiveRelease); + const executable = yield* completedRelease(active.version); + const advisory = yield* compatibility(executable.version); + if (advisory?.status !== "supported") + return yield* installationError( + "resolve", + advisory?.message ?? "Update the managed Codex installation to continue.", + ); + return executable; + }, + Effect.mapError( + wrapFailure("resolve", "Codex is not installed in T3 Code. Install it to continue."), + ), + ); + const acquire = Effect.fn("CodexInstallation.acquire")(function* () { + return yield* Effect.acquireRelease( + gate.withPermit( + resolve().pipe( + Effect.tap(() => + Effect.sync(() => { + leases += 1; + }), + ), + ), + ), + () => + Effect.sync(() => { + leases -= 1; + }), + ); + }); + const runCommand = Effect.fn("CodexInstallation.runCommand")(function* ( + command: string, + args: ReadonlyArray, + ) { + const resolved = yield* resolveSpawnCommand(command, args).pipe( + Effect.provideService(HostProcessPlatform, platform), + ); + const child = yield* spawner.spawn( + ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }), + ); + const [output, , exitCode] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.runDrain), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) + return yield* installationError( + "verify", + "Could not unpack or start the downloaded Codex package.", + ); + return output; + }, Effect.scoped); + const localCache = new Map(); + const resolveLocal = Effect.fn("CodexInstallation.resolveLocal")( + function* () { + const executablePath = yield* resolveCommandPath("codex", { env: environment }).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + // Keep launcher symlinks intact: version-manager shims dispatch by their invoked name. + const realExecutablePath = yield* fs.realPath(executablePath); + // A PATH entry pointing into T3's download remains a managed installation. + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if ( + realExecutablePath.startsWith( + `${Option.getOrElse(realManaged, () => managedDirectory)}${path.sep}`, + ) + ) + return null; + const info = yield* fs.stat(executablePath); + const fingerprint = `${realExecutablePath}:${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`; + const cached = localCache.get(executablePath); + const executable = + cached?.fingerprint === fingerprint + ? cached.executable + : yield* Effect.gen(function* () { + const output = yield* runCommand(executablePath, ["--version"]); + const version = /^codex-cli (\d+\.\d+\.\d+)$/u.exec(output.trim())?.[1]; + if (!version) return null; + yield* runCommand(executablePath, ["app-server", "--help"]); + return { + executablePath, + source: "local", + version, + managedVersionDirectory: null, + } satisfies CodexExecutable; + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => null), + ); + localCache.set(executablePath, { fingerprint, executable }); + if (!executable) return null; + // Cache executable probes, but reclassify against the current manifest on every resolve. + const advisory = yield* compatibility(executable.version); + return advisory?.status === "supported" ? executable : null; + }, + Effect.orElseSucceed(() => null), + ); + const resolve = Effect.fn("CodexInstallation.resolve")(function* () { + const local = yield* resolveLocal(); + return local ?? (yield* resolveManaged()); + }); + const reuseLocal = Effect.fn("CodexInstallation.reuseLocal")(function* () { + const local = yield* resolveLocal(); + if (!local) return false; + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + source: "local", + operationId: null, + installedVersion: local.version, + executablePath: local.executablePath, + downloadedBytes: 0, + totalBytes: null, + message: null, + }) satisfies ProviderInstallState, + ); + return true; + }); + const validate = + options.validate ?? + Effect.fn("CodexInstallation.validate")( + function* (executable: CodexExecutable, version: string) { + const output = yield* runCommand(executable.executablePath, ["--version"]); + if (output.trim() !== `codex-cli ${version}`) + return yield* installationError( + "verify", + "The downloaded Codex executable has the wrong version.", + ); + }, + Effect.mapError(wrapFailure("verify", "The downloaded Codex runtime could not start.")), + ); + const install = Effect.fn("CodexInstallation.install")( + function* (release: CodexReleaseAsset) { + yield* decodeVersion(release.version); + yield* fs.makeDirectory(managedDirectory, { recursive: true }); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true })); + const destination = path.join(managedDirectory, release.version); + const activate = Effect.fn("CodexInstallation.activate")( + function* () { + const executable = yield* completedRelease(release.version); + const temporary = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: ".active-", + }); + const pointer = path.join(temporary, "active.json"); + yield* fs.writeFileString(pointer, yield* encodeActive({ version: release.version }), { + mode: 0o600, + flag: "wx", + }); + yield* fs.rename(pointer, activePath); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "succeeded", + source: "managed", + installedVersion: release.version, + executablePath: executable.executablePath, + message: null, + }) satisfies ProviderInstallState, + ); + }, + Effect.scoped, + Effect.uninterruptible, + ); + if (yield* fs.exists(destination)) { + const existing = yield* completedRelease(release.version); + const record = yield* readRecord( + path.join(destination, ".install-complete.json"), + InstalledRelease, + ); + if (record.sha256 !== release.sha256 || record.target !== release.target) + return yield* installationError( + "verify", + "The existing managed Codex release differs from the official package. Remove it and reinstall.", + ); + yield* validate(existing, release.version).pipe( + Effect.scoped, + Effect.timeout("90 seconds"), + ); + yield* activate(); + return; + } + const staging = yield* fs.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: ".install-", + }); + const archivePath = path.join(staging, "download.tar.gz"); + const runtime = path.join(staging, "runtime"); + yield* fs.makeDirectory(runtime); + const hash = NodeCrypto.createHash("sha256"); + let downloadedBytes = 0; + let lastProgressAt = yield* Clock.currentTimeMillis; + const response = yield* http + .execute(HttpClientRequest.get(release.url)) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + yield* response.stream.pipe( + Stream.tap((chunk) => + Effect.gen(function* () { + downloadedBytes += chunk.byteLength; + if (downloadedBytes > release.archiveBytes) + return yield* installationError( + "download", + "The Codex download exceeded the expected release size.", + ); + hash.update(chunk); + const now = yield* Clock.currentTimeMillis; + if (now - lastProgressAt >= 250 || downloadedBytes === release.archiveBytes) { + lastProgressAt = now; + yield* SubscriptionRef.update(state, (current) => ({ ...current, downloadedBytes })); + } + }), + ), + Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })), + Effect.timeout("45 minutes"), + ); + if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256) + return yield* installationError( + "download", + "The Codex download failed its size or SHA-256 check. Nothing was installed.", + ); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "extracting", + message: "Extracting Codex.", + }) satisfies ProviderInstallState, + ); + const entries = (yield* runCommand("tar", ["-tzf", archivePath])).trim().split("\n"); + const types = (yield* runCommand("tar", ["-tvzf", archivePath])).trim().split("\n"); + if ( + entries.length > 10000 || + entries.length !== types.length || + types.some((line) => !["-", "d"].includes(line[0] ?? "")) || + entries.some((entry) => { + const parts = entry.replace(/\/$/u, "").split("/"); + return ( + !entry || + entry.includes("\\") || + entry.startsWith("/") || + parts.some((part) => part === ".." || part === "." || part === "" || part.includes(":")) + ); + }) + ) + return yield* installationError("extract", "The Codex archive contains unsafe entries."); + yield* runCommand("tar", ["-xzf", archivePath, "-C", runtime]); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "verifying", + message: "Checking Codex.", + }) satisfies ProviderInstallState, + ); + const executable = yield* fromDirectory(runtime, release.version, release.target); + yield* validate(executable, release.version).pipe( + Effect.scoped, + Effect.timeout("90 seconds"), + ); + yield* fs.writeFileString( + path.join(runtime, ".install-complete.json"), + yield* encodeRecord({ + version: release.version, + target: release.target, + sha256: release.sha256, + }), + { flag: "wx", mode: 0o600 }, + ); + yield* fs.rename(runtime, destination); + yield* activate(); + }, + Effect.scoped, + Effect.mapError( + wrapFailure( + "install", + "Could not install Codex. Check disk space and directory access, then try again.", + ), + ), + ); + const start = gate + .withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (isRunning(current)) return current; + if (yield* reuseLocal()) return yield* SubscriptionRef.get(state); + if (!asset) { + return yield* installationError( + "start", + `OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`, + ); + } + const operationId = yield* crypto.randomUUIDv4; + const next: ProviderInstallState = { + driver: DRIVER, + operationId, + phase: "downloading", + downloadedBytes: 0, + totalBytes: asset.archiveBytes, + version: asset.version, + installedVersion: current.installedVersion, + canRemove: current.canRemove, + message: "Downloading Codex.", + }; + yield* SubscriptionRef.set(state, next); + const work = install(asset).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? SubscriptionRef.update(state, (value) => { + if (value.operationId !== operationId || value.phase === "succeeded") + return value; + const error = Cause.findErrorOption(exit.cause); + const cancelled = Cause.hasInterruptsOnly(exit.cause); + return { + ...value, + phase: cancelled ? "cancelled" : "failed", + message: cancelled + ? "Installation cancelled. The previous runtime is unchanged." + : Option.isSome(error) + ? error.value.detail + : "Could not finish the Codex installation. Check disk space and directory access.", + } satisfies ProviderInstallState; + }) + : Effect.void, + ), + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + if (running?.operationId === operationId) running = undefined; + }), + ), + ); + const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope); + running = { operationId, fiber }; + return next; + }).pipe(Effect.uninterruptible), + ) + .pipe(Effect.mapError(wrapFailure("start", "Could not start the Codex installation."))); + + const cancel = Effect.fn("CodexInstallation.cancel")(function* (operationId: string) { + return yield* gate.withPermit( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (current.operationId !== operationId) { + return yield* installationError( + "cancel", + "This installation is no longer current. Refresh its status before cancelling.", + ); + } + if (running?.operationId === operationId && isRunning(current)) { + yield* Fiber.interrupt(running.fiber); + } + return yield* SubscriptionRef.get(state); + }), + ); + }); + + const remove = Effect.fn("CodexInstallation.remove")( + function* (protectedBinaryPaths: ReadonlyArray = []) { + yield* gate.withPermit( + Effect.gen(function* () { + if (isRunning(yield* SubscriptionRef.get(state)) || leases > 0) { + return yield* installationError( + "remove", + "Stop Codex sessions and sign-in flows before removing its managed runtime.", + ); + } + const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option); + if (Option.isSome(realManaged)) { + for (const binary of protectedBinaryPaths) { + const resolved = yield* fs.realPath(binary).pipe(Effect.option); + const candidate = Option.getOrElse(resolved, () => path.resolve(binary)); + if (candidate.startsWith(`${realManaged.value}${path.sep}`)) + return yield* installationError( + "remove", + "A provider instance uses a custom path inside managed Codex. Clear that path before removing it.", + ); + } + } + yield* fs.remove(managedDirectory, { recursive: true, force: true }); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + operationId: null, + phase: "idle", + downloadedBytes: 0, + installedVersion: null, + executablePath: null, + source: null, + canRemove: false, + message: null, + }) satisfies ProviderInstallState, + ); + yield* reuseLocal(); + }).pipe(Effect.uninterruptible), + ); + }, + Effect.mapError( + wrapFailure( + "remove", + "Could not remove the managed Codex runtime. Check for open processes and try again.", + ), + ), + ); + + yield* Effect.gen(function* () { + const canRemove = yield* fs.exists(managedDirectory); + yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove })); + if (yield* reuseLocal()) return; + if (!(yield* fs.exists(activePath))) return; + const active = yield* readRecord(activePath, ActiveRelease); + const installed = yield* completedRelease(active.version); + yield* SubscriptionRef.update( + state, + (current) => + ({ + ...current, + installedVersion: installed.version, + executablePath: installed.executablePath, + source: "managed", + }) satisfies ProviderInstallState, + ); + }).pipe( + Effect.catch(() => + SubscriptionRef.update( + state, + (current) => + ({ + ...current, + phase: "failed", + message: "The managed Codex runtime is incomplete. Remove it and reinstall.", + }) satisfies ProviderInstallState, + ), + ), + ); + + return CodexInstallation.of({ + managedDirectory, + resolve, + acquire, + start, + cancel, + state: SubscriptionRef.get(state), + changes: SubscriptionRef.changes(state), + remove, + }); +}); diff --git a/apps/server/src/provider/CodexManagedErrors.test.ts b/apps/server/src/provider/CodexManagedErrors.test.ts new file mode 100644 index 000000000000..a1f253c922ed --- /dev/null +++ b/apps/server/src/provider/CodexManagedErrors.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import { classifyCodexManagedError } from "./CodexManagedErrors.ts"; +it("maps streamed failures to safe actionable messages and reconnect decisions", () => { + assert.deepEqual( + classifyCodexManagedError({ + error: { + code: "subscription_sharing_v2_invalid_user", + message: "opaque token dummy-sensitive", + }, + }), + { + message: + "ChatGPT could not validate this connection. Check the selected account and sharing permissions.", + revoke: false, + code: "subscription_sharing_v2_invalid_user", + }, + ); + assert.include( + classifyCodexManagedError("subscription_sharing_usage_limit_exceeded")!.message, + "Usage settings", + ); + assert.equal( + 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", + ); + assert.isUndefined(classifyCodexManagedError(undefined)); + assert.isUndefined(classifyCodexManagedError({ error: "unknown" })); +}); + +it("distinguishes unsupported tools from input items without exposing the raw response", () => { + const code = "subscription_sharing_unsupported_capability"; + for (const [detail, expected] of [ + ["tool 'namespace' is not supported", "tool namespace"], + ["input item 'additional_tools' is not supported", "input item"], + ]) { + const response = { error: { code, message: `${detail}; dummy-sensitive` } }; + for (const value of [response, JSON.stringify(response)]) { + const failure = classifyCodexManagedError(value); + assert.isDefined(failure); + assert.include(failure!.message, expected!); + assert.notInclude(failure!.message, "dummy-sensitive"); + assert.isFalse(failure!.revoke); + } + } +}); + +it("preserves credentials for the current subscriber and permission error codes", () => { + for (const code of [ + "subscription_sharing_invalid_user", + "subscription_sharing_user_not_eligible", + "subscription_sharing_route_not_supported", + "subscription_sharing_user_unavailable", + "chatpass_v2_scope_not_authorized", + "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 new file mode 100644 index 000000000000..9c8fe26bbb6e --- /dev/null +++ b/apps/server/src/provider/CodexManagedErrors.ts @@ -0,0 +1,87 @@ +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_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 = { + ...legacyFailures, + subscription_sharing_user_not_eligible: legacyFailures.subscription_sharing_v2_user_not_eligible, + subscription_sharing_route_not_supported: + legacyFailures.subscription_sharing_v2_route_not_supported, + subscription_sharing_invalid_user: legacyFailures.subscription_sharing_v2_invalid_user, + subscription_sharing_user_unavailable: legacyFailures.subscription_sharing_v2_user_unavailable, + 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) { + let text: string; + try { + text = typeof value === "string" ? value : JSON.stringify(value); + } catch { + return undefined; + } + if (typeof text !== "string") return undefined; + for (const [code, failure] of Object.entries(failures)) { + if (!text.includes(code)) continue; + if (code === "subscription_sharing_unsupported_capability") { + if (text.includes("tool 'namespace'")) + return { + ...failure, + code, + message: + "Codex sent a tool namespace that ChatGPT sharing does not support. Use another provider for this request.", + }; + if (text.includes("additional_tools")) + return { + ...failure, + code, + message: + "Codex sent an input item that ChatGPT sharing does not support. Use another provider for this request.", + }; + } + return { ...failure, code }; + } + return undefined; +} diff --git a/apps/server/src/provider/CodexManagedHome.ts b/apps/server/src/provider/CodexManagedHome.ts new file mode 100644 index 000000000000..7f6d6313e11e --- /dev/null +++ b/apps/server/src/provider/CodexManagedHome.ts @@ -0,0 +1,20 @@ +import type { CodexSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; + +export const resolveManagedCodexHomeLayout = Effect.fn("resolveManagedCodexHomeLayout")(function* ( + stateDir: string, + instanceId: ProviderInstanceId, + config: CodexSettings, +) { + const path = yield* Path.Path; + return yield* resolveCodexHomeLayout({ + ...config, + shadowHomePath: + config.shadowHomePath.trim() || + (instanceId === "codex" + ? "" + : path.join(stateDir, "providers", "codex", instanceId, "shadow")), + }); +}); diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts new file mode 100644 index 000000000000..aeba7c5bfe3d --- /dev/null +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -0,0 +1,225 @@ +// @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ServerConfig } from "../config.ts"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../environment/ServerEnvironment.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; +import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; +import { codexAppServerArgs } from "./Layers/codexLaunchArgs.ts"; +import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeSettings = Schema.decodeSync(CodexSettings); +it.effect("managed home defaults to the global Codex home and honors configured home paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const primary = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex"), + decodeSettings({}), + ); + assert.equal(primary.sharedHomePath, path.join(NodeOS.homedir(), ".codex")); + assert.equal(primary.mode, "direct"); + const additional = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex-work"), + decodeSettings({}), + ); + assert.equal(additional.sharedHomePath, primary.sharedHomePath); + assert.equal(additional.mode, "authOverlay"); + const configured = yield* resolveManagedCodexHomeLayout( + "/t3-state", + ProviderInstanceId.make("codex-work"), + decodeSettings({ homePath: "/custom/shared", shadowHomePath: "/custom/shadow" }), + ); + assert.equal(configured.sharedHomePath, "/custom/shared"); + assert.equal(configured.effectiveHomePath, "/custom/shadow"); + }).pipe(Effect.provide(NodeServices.layer)), +); +for (const source of ["managed", "local"] as const) + for (const account of ["primary", "additional"] as const) + it.effect( + `${source} Codex ${account} account shares home state without routing owned tokens through ambient CLI overrides`, + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make( + account === "primary" ? "codex" : "codex-personal", + ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* fs.makeTempDirectoryScoped({ prefix: "codex-shared-home-" }); + yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); + yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); + const data = new Map(); + const secrets = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), + set: (key, value) => + Effect.sync(() => { + data.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + data.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + let leases = 0; + const executable = { + executablePath: + source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", + managedVersionDirectory: source === "managed" ? "/isolated/tools/codex/0.156.1" : null, + source, + version: "0.156.1", + }; + const installerLayer = Layer.mock(CodexInstallation)({ + managedDirectory: "/isolated/tools/codex", + resolve: () => Effect.succeed(executable), + acquire: () => + Effect.gen(function* () { + leases++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + leases--; + }), + ); + return executable; + }), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeJson({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: null, + }); + yield* store.set(new TextEncoder().encode(json)); + const ambient = { + CODEX_HOME: "/user/.codex", + OPENAI_API_KEY: "dummy-global-key", + OPENAI_BASE_URL: "https://user-proxy.test", + T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", + PATH: "/usr/bin", + }; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled: true, + config: decodeSettings({ setupMode: "managed", homePath: sharedHome }), + environment: ambient, + }); + yield* Effect.gen(function* () { + const effective = yield* runtime.resolve; + assert.strictEqual(leases, 1); + assert.strictEqual(effective.config.binaryPath, executable.executablePath); + assert.notStrictEqual(effective.config.homePath, ambient.CODEX_HOME); + assert.equal(runtime.homeLayout.sharedHomePath, sharedHome); + if (account === "primary") { + assert.equal(effective.config.homePath, sharedHome); + assert.equal(runtime.homeLayout.mode, "direct"); + } else { + assert.include(effective.config.homePath, instanceId); + assert.include(effective.config.homePath, "userdata/providers/codex"); + assert.equal(runtime.homeLayout.mode, "authOverlay"); + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "sessions")), + path.join(sharedHome, "sessions"), + ); + assert.equal( + yield* fs.readLink(path.join(effective.config.homePath, "config.toml")), + path.join(sharedHome, "config.toml"), + ); + assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); + } + assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + assert.isUndefined(effective.environment.OPENAI_API_KEY); + assert.isUndefined(effective.environment.OPENAI_BASE_URL); + assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); + const args = codexAppServerArgs(effective.config.launchArgs); + assert.include( + args, + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + ); + assert.include( + args, + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', + ); + assert.include(args, "features.api_key_model_discovery=true"); + assert.notInclude(effective.config.launchArgs, "model_catalog_json"); + assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); + assert.include( + args, + "model_providers.openai_token_sharing.supports_websockets=false", + ); + assert.include( + args, + "model_providers.openai_token_sharing.requires_openai_auth=false", + ); + assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); + assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); + }).pipe(Effect.scoped); + assert.strictEqual(leases, 0); + yield* runtime.auth.controller.logout(Effect.void); + assert.equal( + yield* fs.readFileString(path.join(sharedHome, "auth.json")), + "native-auth-unchanged", + ); + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + Effect.provide(installerLayer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + assert.isTrue( + [ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + ].includes(request.url), + ); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), + ), + ), + ); + }).pipe( + Effect.scoped, + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts new file mode 100644 index 000000000000..528673fc3406 --- /dev/null +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -0,0 +1,112 @@ +import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; +import { CodexSettings, ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ServerConfig } from "../config.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; +import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; +import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; + +export interface CodexEffectiveRuntime { + readonly config: CodexSettings; + readonly environment: NodeJS.ProcessEnv; + readonly revision: string; +} +const decodeSettings = Schema.decodeSync(CodexSettings); +// Managed sign-in stores tokens in T3's credential store and never writes native auth.json. +const managedCodexLaunchArgs = [ + 'model_provider="openai_token_sharing"', + 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"', + 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', + 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', + "features.api_key_model_discovery=true", + 'model_providers.openai_token_sharing.env_key="ACCESS_TOKEN"', + 'model_providers.openai_token_sharing.wire_api="responses"', + "model_providers.openai_token_sharing.requires_openai_auth=false", + "model_providers.openai_token_sharing.supports_websockets=false", +] + .map((value) => `-c '${value}'`) + .join(" "); + +export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly enabled: boolean; + readonly environment: NodeJS.ProcessEnv; + readonly config: CodexSettings; +}) { + const installation = yield* CodexInstallation; + const config = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const auth = yield* makeCodexChatGptAuth({ + instanceId: options.instanceId, + defaultReturnUrl: new URL( + "/welcome", + config.devUrl ?? `http://localhost:${config.port}`, + ).toString(), + }); + const homeLayout = yield* resolveManagedCodexHomeLayout( + config.stateDir, + options.instanceId, + options.config, + ); + const homePath = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; + const resolve = Effect.gen(function* () { + const executable = yield* installation.acquire().pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "install", + detail: "Set up managed Codex before starting a session.", + }), + ), + ); + const credentials = yield* auth.access; + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + detail: cause.message, + }), + ), + ); + yield* fs.makeDirectory(homePath, { recursive: true }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + detail: "Could not prepare the managed Codex runtime.", + }), + ), + ); + // Ambient CLI overrides cannot redirect a T3-owned token to a different provider. + const environment: NodeJS.ProcessEnv = { + ...options.environment, + ACCESS_TOKEN: credentials.accessToken, + CODEX_HOME: homePath, + }; + delete environment.T3CODE_CODEX_LAUNCH_ARGS; + delete environment.OPENAI_API_KEY; + delete environment.OPENAI_BASE_URL; + return { + config: decodeSettings({ + enabled: options.enabled, + setupMode: "managed", + binaryPath: executable.executablePath, + homePath, + launchArgs: managedCodexLaunchArgs, + }), + environment, + revision: credentials.accessToken, + } satisfies CodexEffectiveRuntime; + }); + return { auth, resolve, installation, homePath, homeLayout }; +}); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index cc9ccb074b9e..18f7aaffee06 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -31,8 +31,7 @@ import { } from "../AntigravityInstallation.ts"; import { ANTIGRAVITY_AUTH_STDOUT_PREFIX, - resolveAntigravityProfileDirectory, - resolveAntigravityRuntimeTempDirectory, + resolveAntigravityInstanceDirectories, } from "../antigravityAuthSupport.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; @@ -74,7 +73,8 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), ); const requestLog = path.join(root, "requests.jsonl"); - const profileDirectory = resolveAntigravityProfileDirectory(config.stateDir, instanceId); + const directories = yield* resolveAntigravityInstanceDirectories(config.stateDir, instanceId); + const profileDirectory = directories.profile; const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`; const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( @@ -233,6 +233,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( fs, path, profileDirectory, + directories, instancePath, first, second, @@ -475,7 +476,7 @@ it.layer(testLayer)("AntigravityDriver", (it) => { () => Effect.gen(function* () { const h = yield* makeHarness(); - const tempRoot = resolveAntigravityRuntimeTempDirectory(h.profileDirectory); + const tempRoot = h.directories.runtimeTemp; yield* h.refresh(); yield* h.refresh(); const directories = h.launches.flatMap((launch) => @@ -498,12 +499,17 @@ it.layer(testLayer)("AntigravityDriver", (it) => { const path = yield* Path.Path; const config = yield* ServerConfig; const instanceId = ProviderInstanceId.make("antigravity-orphan-sweep"); - const tempRoot = resolveAntigravityRuntimeTempDirectory( - resolveAntigravityProfileDirectory(config.stateDir, instanceId), + const directories = yield* resolveAntigravityInstanceDirectories( + config.stateDir, + instanceId, ); - const orphan = path.join(tempRoot, "run-orphan", "_MEI123", "google3"); - yield* fs.makeDirectory(orphan, { recursive: true }); - yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale"); + // Older builds unpacked inside the profile. + const legacyRoot = path.join(directories.profile, "antigravity-acp", "tmp"); + for (const root of [directories.runtimeTemp, legacyRoot]) { + const orphan = path.join(root, "run-orphan", "_MEI123", "google3"); + yield* fs.makeDirectory(orphan, { recursive: true }); + yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale"); + } yield* AntigravityDriver.create({ instanceId, displayName: "Sweep", @@ -519,7 +525,8 @@ it.layer(testLayer)("AntigravityDriver", (it) => { }), ), ); - expect(yield* fs.exists(tempRoot)).toBe(false); + expect(yield* fs.exists(directories.runtimeTemp)).toBe(false); + expect(yield* fs.exists(legacyRoot)).toBe(false); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1141ac5856fc..fb9c7041b5fd 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -33,8 +33,7 @@ import { buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, prepareAntigravityProfile, - resolveAntigravityProfileDirectory, - resolveAntigravityRuntimeTempDirectory, + resolveAntigravityInstanceDirectories, type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { @@ -103,15 +102,34 @@ export const AntigravityDriver: ProviderDriver + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not resolve the Antigravity profile directory.", + cause, + }), + ), ); + const profileDirectory = directories.profile; // No process of this instance exists yet, so every runtime temp - // directory left under the profile is an orphan from a killed server. - yield* removeAntigravityRuntimeTempDirs( - resolveAntigravityRuntimeTempDirectory(profileDirectory), - ).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + // directory it owns is an orphan from a killed server. Older builds + // unpacked inside the profile. + for (const directory of [ + directories.runtimeTemp, + path.join(profileDirectory, "antigravity-acp", "tmp"), + ]) { + yield* removeAntigravityRuntimeTempDirs(directory).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + ); + } const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER, instanceId, @@ -165,6 +183,7 @@ export const AntigravityDriver: ProviderDriver( Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(undefined) + ? Effect.undefined : Effect.fail( new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }), ), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b87a89dcf528..b0d7ec6d3ba6 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -31,6 +31,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; +import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, makePendingClaudeProvider, @@ -59,7 +61,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -87,6 +93,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -111,6 +118,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; @@ -139,6 +147,12 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, processEnv, ); + const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); + const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( + effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() + ? configDir + : undefined, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -193,6 +207,12 @@ export const ClaudeDriver: ProviderDriver = { cwd, resolveClaudeModelCatalog(manifest), scopedLimitNames, + (version) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ), ), Effect.map(stampIdentity), @@ -250,6 +270,68 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); + // Same rules as Codex: serialised on the config directory that holds the + // login, one request id kept until Claude answers (a cooldown or rate + // limit is an answer), then a re-probe. + const consumeResetCredit: NonNullable = () => + Effect.gen(function* () { + const current = yield* snapshot.getSnapshot; + const grantId = current.usageLimits?.resetCredits?.nextCreditId; + if (!grantId || !current.version) return "noCredit" as const; + const version = current.version; + return yield* resetCreditCoordinator.redeem( + configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + configDir, + accountConfigPath, + version, + grantId, + requestId, + }), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, + ); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "ClaudeResetCreditError" + ? cause.message + : "Claude could not redeem the reset.", + cause, + }), + ), + // Re-probe after any answer, but only a reset claims the limits + // changed, so only a reset reports an unconfirmed refresh. + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + yield* Cache.invalidateAll(capabilitiesProbeCache); + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, @@ -261,9 +343,11 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, + consumeResetCredit, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index bac34db452fd..c9b11ffabd8f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,23 +1,31 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; 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 Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -26,14 +34,24 @@ import { resolveLatestProviderVersion, } from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; +import * as ProviderCredentialStore from "../ProviderCredentialStore.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-driver-maintenance-", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), + ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(codexResetCreditLayerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( Layer.mock(BackgroundPolicy.BackgroundPolicy)({ shouldRunScopeWork: () => Effect.succeed(false), @@ -54,14 +72,144 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32"; const noSpawn = ChildProcessSpawner.make(() => Effect.die("Disabled Codex must not spawn a process"), ); +const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(testLayer)("CodexDriver", (it) => { + it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("restored-managed-account"); + const credentials = new Map(); + const secrets = ServerSecretStore.of({ + get: (key) => Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), + set: (key, value) => + Effect.sync(() => { + credentials.set(key, value); + }), + remove: (key) => + Effect.sync(() => { + credentials.delete(key); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + yield* Effect.gen(function* () { + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const json = yield* encodeCredentials({ + clientId: "oaiapp_test", + accessToken: "dummy-owned-access", + refreshToken: "dummy-refresh", + expiresAt: Number.MAX_SAFE_INTEGER, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: "account@example.test", + }); + yield* store.set(new TextEncoder().encode(json)); + const executable = { + executablePath: "/user/bin/codex", + managedVersionDirectory: null, + source: "local" as const, + version: "0.156.1", + }; + const installation = yield* CodexInstallation; + const serverConfig = yield* ServerConfig; + const sharedHome = NodePath.join(serverConfig.stateDir, "shared-codex-home"); + const instance = yield* CodexDriver.create({ + instanceId, + displayName: "Restored account", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), setupMode: "managed", homePath: sharedHome }, + }).pipe( + Effect.provideService( + CodexInstallation, + CodexInstallation.of({ + ...installation, + managedDirectory: "unused-managed-installation", + resolve: () => Effect.succeed(executable), + acquire: () => Effect.succeed(executable), + }), + ), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.badArgument({ + module: "ChildProcessSpawner", + method: "spawn", + description: "The fixture app-server is unavailable", + }), + ), + ), + ), + ); + const observedAccount = yield* Deferred.make(); + const disconnected = yield* instance.snapshot.streamChanges.pipe( + Stream.tap((provider) => + provider.auth.status === "authenticated" + ? Deferred.succeed(observedAccount, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Stream.filter((provider) => provider.auth.status === "unauthenticated"), + Stream.runHead, + Effect.forkScoped, + ); + const restored = yield* instance.snapshot.refresh; + expect(restored.auth.email).toBe("account@example.test"); + expect(restored.runtimePaths?.homePath).toBe(sharedHome); + expect(restored.runtimePaths?.shadowHomePath).toContain( + `providers/codex/${instanceId}/shadow`, + ); + yield* Deferred.await(observedAccount); + const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead); + expect(Option.getOrThrow(before).phase).toBe("idle"); + yield* instance.auth!.logout(Effect.void); + const after = Option.getOrThrow(yield* Fiber.join(disconnected)); + expect(after.auth.status).toBe("unauthenticated"); + expect(after.auth.email).toBeUndefined(); + expect(after.installed).toBe(true); + expect(after.models).toEqual([]); + expect(Option.isNone(yield* store.get)).toBe(true); + }).pipe( + Effect.provideService(ServerSecretStore, secrets), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + expect([ + "https://auth.openai.com/.well-known/openid-configuration", + "https://auth.openai.com/revoke", + "https://api.openai.com/v1/models", + ]).toContain(request.url); + if (request.url.endsWith("/models")) + return HttpClientResponse.fromWeb(request, Response.json({ models: [] })); + return HttpClientResponse.fromWeb( + request, + request.url.endsWith("/revoke") + ? new Response(null, { status: 200 }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), + ); + }), + ), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)( "runs the standalone updater against the shared home, not the shadow home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const sharedHome = NodePath.join(tempDir, "codex-home"); const shadowHome = NodePath.join(tempDir, "codex-shadow"); const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex"); @@ -136,7 +284,9 @@ it.layer(testLayer)("CodexDriver", (it) => { it.effect.skipIf(windowsHost)(fixture.name, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const installPath = NodePath.join(tempDir, ...fixture.installSegments); const realBinaryPath = NodePath.join( installPath, @@ -193,7 +343,9 @@ it.layer(testLayer)("CodexDriver", (it) => { it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const binaryPath = layout === "direct" ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") @@ -268,7 +420,9 @@ it.layer(testLayer)("CodexDriver", (it) => { (fixture) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); const brewPrefix = NodePath.join(tempDir, "homebrew"); const brewPath = NodePath.join(brewPrefix, "bin", "brew"); const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); @@ -361,7 +515,11 @@ it.layer(testLayer)("CodexDriver", (it) => { if (fixture.nodeFirst) { expect(capabilities.update).toMatchObject({ executable: "npm", - args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + args: expect.arrayContaining([ + "--prefix", + yield* fs.realPath(npmPrefix), + "@openai/codex@latest", + ]), }); } else { expect(capabilities.update).toBeNull(); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 22dd047f5c69..15ee69264c13 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { - CODEX_RESET_CREDIT_TIMEOUT, - CodexResetCreditCoordinator, -} from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkCodexProviderStatus, makePendingCodexProvider, @@ -71,6 +68,10 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -106,7 +107,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { export type CodexDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner - | CodexResetCreditCoordinator + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -114,7 +115,10 @@ export type CodexDriverEnv = | Path.Path | ProviderEventLoggers | ServerConfig - | ServerSettingsService; + | ServerSettingsService + | ServerSecretStore + | ServerEnvironmentIdentity + | CodexInstallation; export const CodexDriver: ProviderDriver = { driverKind: DRIVER_KIND, @@ -126,8 +130,17 @@ export const CodexDriver: ProviderDriver = { defaultConfig: (): CodexSettings => decodeCodexSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + if (config.setupMode === "managed") + return yield* makeManagedCodexProvider({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resetCreditCoordinator = yield* CodexResetCreditCoordinator; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -175,18 +188,6 @@ export const CodexDriver: ProviderDriver = { ), ); - // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error - // channels at construction time — their failure modes are all on the - // per-operation closures they return. No `mapError` wrapper is needed - // here; the registry only has to worry about snapshot-build and - // spawner-availability failures surfaced from `checkCodexProviderStatus` - // below. - const adapter = yield* makeCodexAdapter(effectiveConfig, { - instanceId, - environment: processEnv, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - }); - // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits @@ -241,11 +242,20 @@ export const CodexDriver: ProviderDriver = { }), ), ); - const textGeneration = yield* makeCodexTextGeneration( - effectiveConfig, - processEnv, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), - ); + const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models)); + // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error + // channels at construction time — their failure modes are all on the + // per-operation closures they return. No `mapError` wrapper is needed + // here; the registry only has to worry about snapshot-build and + // spawner-availability failures surfaced from `checkCodexProviderStatus` + // above. + const adapter = yield* makeCodexAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + models, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv, models); const snapshotForCwd = (cwd: string) => !effectiveConfig.enabled ? snapshot.getSnapshot @@ -299,7 +309,7 @@ export const CodexDriver: ProviderDriver = { idempotencyKey, }); return response.outcome; - }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)), + }).pipe(Effect.scoped, Effect.timeout("20 seconds")), ) .pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -315,16 +325,19 @@ export const CodexDriver: ProviderDriver = { // The windows just changed; re-probe so the snapshot says so. A // failed probe republishes the pre-redemption limits rather than // marking them failed, so "confirmed" means `checkedAt` moved - // past what was published before the redemption started. - Effect.tap(() => + // past what was published before the redemption started. Only a + // reset claims the limits changed, so only a reset reports an + // unconfirmed refresh. + Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; const refreshed = yield* snapshot.refresh; const after = refreshed.usageLimits?.checkedAt; if ( - after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed" + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") ) { return yield* new ProviderDriverError({ driver: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts new file mode 100644 index 000000000000..cc6b185d4fd1 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -0,0 +1,280 @@ +import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { chatGptModels } from "../CodexChatGptModels.ts"; +import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; +import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; +import { withInstanceIdentity } from "./instanceIdentity.ts"; +import { HttpClient } from "effect/unstable/http"; +const DRIVER = ProviderDriverKind.make("codex"); + +export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* ( + input: ProviderDriverCreateInput, +) { + const { instanceId, enabled, displayName, accentColor, config } = input; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const settings = yield* ServerSettingsService; + const runtime = yield* makeCodexManagedRuntime({ + instanceId, + enabled, + config, + environment: mergeProviderInstanceEnvironment(input.environment), + }); + const continuationIdentity = codexContinuationIdentity(runtime.homeLayout); + const stamp = withInstanceIdentity({ + instanceId, + driverKind: DRIVER, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const setup = { canAuthenticate: true, canInstall: true }; + const runtimePaths = { + homePath: runtime.homeLayout.sharedHomePath, + shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null, + }; + const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe( + Effect.map((draft) => + stamp({ + ...draft, + models: [], + setup, + runtimePaths, + }), + ), + ); + const check = Effect.gen(function* () { + const base = yield* pending; + if (!enabled) return base; + const executable = yield* runtime.installation.resolve().pipe(Effect.option); + if (Option.isNone(executable)) + return { + ...base, + installed: false, + models: [], + message: "Set up Codex to get started.", + auth: { status: "unauthenticated" as const }, + }; + const saved = yield* runtime.auth.read.pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(saved) && !saved.value.scopes.includes("chatgpt.tokens.use.direct")) + return { + ...base, + installed: true, + version: executable.value.version, + models: [], + message: + "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.", + auth: { + status: "unauthenticated" as const, + label: "ChatGPT", + ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}), + }, + }; + if (Option.isNone(saved)) + return { + ...base, + installed: true, + version: executable.value.version, + models: [], + message: "Sign in with ChatGPT to use Codex.", + auth: { status: "unauthenticated" as const }, + }; + const usageLimits = { + checkedAt: base.checkedAt, + windows: [], + unavailable: { + reason: "unsupported" as const, + message: + "ChatGPT tracks subscription usage across connected apps. Open Usage settings with the account you connected to Codex.", + }, + externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" }, + }; + const managedAuth = { + subscriptionSharing: true, + profileId: saved.value.clientId, + status: "authenticated" as const, + type: "chatgpt", + label: "ChatGPT", + ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}), + }; + return yield* runtime.auth.controller.withAccess!(runtime.resolve).pipe( + Effect.flatMap((effective) => + Effect.gen(function* () { + const draft = yield* checkCodexProviderStatus( + effective.config, + undefined, + effective.environment, + managedAuth, + ); + const models = yield* chatGptModels( + effective.environment.ACCESS_TOKEN!, + draft.models, + ).pipe(Effect.provideService(HttpClient.HttpClient, http)); + return { ...draft, models }; + }), + ), + Effect.map((draft) => + stamp({ + ...draft, + auth: managedAuth, + version: draft.version ?? executable.value.version, + usageLimits, + models: draft.models.map((model) => ({ + ...model, + ...(model.capabilities + ? { + capabilities: { + ...model.capabilities, + optionDescriptors: (model.capabilities.optionDescriptors ?? []).filter( + (option) => option.id !== "serviceTier", + ), + }, + } + : {}), + })), + setup, + runtimePaths, + ...(draft.slashCommands + ? { + slashCommands: draft.slashCommands.filter((command) => command.name !== "feedback"), + } + : {}), + }), + ), + Effect.catch(() => + runtime.auth.read.pipe( + Effect.orElseSucceed(() => Option.none()), + Effect.map((current) => ({ + ...base, + installed: true, + version: executable.value.version, + models: [], + auth: { + status: + Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") + ? ("authenticated" as const) + : ("unauthenticated" as const), + label: "ChatGPT", + ...(Option.isSome(current) && + current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { subscriptionSharing: true, profileId: current.value.clientId } + : {}), + ...(Option.isSome(current) && current.value.email?.trim() + ? { email: current.value.email.trim() } + : {}), + }, + ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { usageLimits } + : {}), + message: "Could not check Codex right now. Retry, or reconnect in provider settings.", + })), + ), + ), + Effect.scoped, + ); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + const snapshot = yield* makeManagedServerProvider({ + resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }), + getSettings: settings.getSettings, + streamSettings: settings.streamChanges, + haveSettingsChanged: () => false, + initialSnapshot: () => pending, + checkProvider: check, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not prepare managed Codex.", + cause, + }), + ), + ); + yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe( + // Disconnect also publishes idle when a saved account has no active sign-in flow. + Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runForEach(() => snapshot.refresh.pipe(Effect.asVoid)), + Effect.forkScoped, + ); + const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); + const adapter = yield* makeCodexAdapter(config, { + instanceId, + resolveRuntime, + onManagedConnectionRevoked: runtime.auth.revoke.pipe(Effect.ignore), + }); + const nativeGeneration = yield* makeCodexTextGeneration( + config, + undefined, + snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + resolveRuntime, + ); + const protect = (operation: string, effect: Effect.Effect) => + runtime.auth.controller.withAccess!(effect).pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "detail" in cause ? cause.detail : "Codex text generation failed.", + }), + ), + ); + const textGeneration: ProviderInstance["textGeneration"] = { + generateCommitMessage: (value) => + protect("generateCommitMessage", nativeGeneration.generateCommitMessage(value)), + generatePrContent: (value) => + protect("generatePrContent", nativeGeneration.generatePrContent(value)), + generateBranchName: (value) => + protect("generateBranchName", nativeGeneration.generateBranchName(value)), + generateThreadTitle: (value) => + protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)), + }; + return { + instanceId, + driverKind: DRIVER, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + auth: runtime.auth.controller, + snapshotForCwd: (cwd: string) => + enabled + ? resolveRuntime.pipe( + Effect.flatMap((effective) => + probeCodexSkillsForCwd({ + binaryPath: effective.config.binaryPath, + homePath: effective.config.homePath, + launchArgs: effective.config.launchArgs, + cwd, + environment: effective.environment, + }), + ), + Effect.flatMap((skills) => + snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))), + ), + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.catch(() => snapshot.getSnapshot), + ) + : snapshot.getSnapshot, + } satisfies ProviderInstance; +}); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 70af46ff6867..521c1b943e84 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -134,18 +134,29 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readCursorUsageLimits(effectiveConfig, processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) - : Effect.succeed(snapshot), + Effect.filterOrElse( + (snapshot) => + !( + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const usageLimits = yield* readCursorUsageLimits( + effectiveConfig, + processEnv, + settings.cursorKeychainUsageEnabled, + ); + return { ...snapshot, usageLimits }; + }), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -217,6 +228,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts new file mode 100644 index 000000000000..916028edc60c --- /dev/null +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -0,0 +1,97 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { HostProcessPlatform } 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 { HttpClient } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { GrokDriver } from "./GrokDriver.ts"; + +const testLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-grok-driver-update-", +}).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunScopeWork: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")), + ), + ), +); + +const noSpawner = ChildProcessSpawner.make(() => + Effect.die("Disabled Grok must not spawn a process"), +); + +// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows. +const windowsHost = HostProcessPlatform.defaultValue() === "win32"; + +it.layer(testLayer)("GrokDriver", (it) => { + it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" }); + const grokHome = path.join(tempDir, "Grok Home"); + const binaryPath = path.join(grokHome, "bin", "grok"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-update"), + displayName: "Grok test", + enabled: false, + environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], + config: { ...GrokDriver.defaultConfig(), binaryPath }, + }); + + const capabilities = yield* instance.snapshot.resolveMaintenance(); + expect(capabilities.packageName).toBe("@xai-official/grok"); + expect(capabilities.update).toMatchObject({ + command: `'${binaryPath}' update`, + executable: binaryPath, + args: ["update"], + }); + // `grok update` installs under GROK_HOME, so it must target this instance's home. + expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); + + it.effect("stays manual-only when the configured executable does not exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" }); + const instance = yield* GrokDriver.create({ + instanceId: ProviderInstanceId.make("grok-missing"), + displayName: "Grok test", + enabled: false, + environment: [], + config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") }, + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner), + Effect.scoped, + ), + ); +}); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 5f0cf4d90c71..70d7a3f6e378 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -19,7 +19,7 @@ import { enrichGrokSnapshot, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; -import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts"; +import { readGrokAccount } from "../Layers/grokUsageLimits.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -29,7 +29,13 @@ import { import { withInstanceIdentity } from "./instanceIdentity.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { discoverGrokSkills } from "./GrokSkills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + makeCachedProviderMaintenanceResolution, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -38,10 +44,32 @@ import { const decodeGrokSettings = Schema.decodeSync(GrokSettings); const DRIVER_KIND = ProviderDriverKind.make("grok"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, -}); +// npm's `latest` tracks Grok's stable channel, the one `grok update` installs +// by default, so the registry stays the source for "latest". +const GROK_NPM_PACKAGE = "@xai-official/grok"; +// `grok update` finds the installer that owns the binary itself, so the +// resolved executable is its own updater. It installs under `GROK_HOME`, so it +// runs with the instance's environment. No executable means nothing to update, +// not "whatever is on PATH". +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (context) => + Effect.succeed( + context + ? makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["update"], + updateLockKey: "grok", + platform: context.platform, + env: context.env, + }) + : makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: GROK_NPM_PACKAGE, + }), + ), +}; export type GrokDriverEnv = | BackgroundPolicy.BackgroundPolicy @@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies GrokSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); const adapter = yield* makeGrokAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -93,12 +131,22 @@ export const GrokDriver: ProviderDriver = { const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readGrokUsageLimits(processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) - : Effect.succeed(snapshot), + Effect.filterOrElse( + (snapshot) => + !( + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + readGrokAccount(processEnv).pipe( + // The email lets clients recognize one account signed in on several environments. + Effect.map(({ email, usageLimits }) => ({ + ...snapshot, + auth: email ? { ...snapshot.auth, email } : snapshot.auth, + usageLimits, + })), + ), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -110,7 +158,7 @@ export const GrokDriver: ProviderDriver = { const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -118,13 +166,17 @@ export const GrokDriver: ProviderDriver = { buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => - enrichGrokSnapshot({ - snapshot: currentSnapshot, - maintenanceCapabilities: MAINTENANCE_CAPABILITIES, - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - publishSnapshot, - httpClient, - }), + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichGrokSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + ), + ), }).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 4bd0bb3e1010..cd245570d1ad 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -775,6 +775,44 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); + it.effect("stops commands left running after a turn when the idle turn is stopped", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Start a watcher" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "watcher-1", + kind: "execute", + status: "inProgress", + command: "tail -f log", + data: {}, + }, + rawPayload: {}, + }); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + const started = yield* h.waitForEvent((event) => event.type === "task.started"); + + // Monitoring's Stop reaches the adapter as a turn interrupt. With no + // prompt to cancel, it has to end the session to stop the command. + yield* h.adapter.interruptTurn(threadId); + const stopped = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(stopped.payload).toMatchObject({ taskId: started.payload.taskId, status: "stopped" }); + yield* h.waitForEvent((event) => event.type === "session.exited"); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + expect(h.controls.closed).toBe(1); + }), + ); + it.effect("keeps a launched batch active while child tools continue", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 61a9b3c3a645..c5e6400d11b4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -786,9 +786,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi stopOwned, Effect.gen(function* () { const mcp = McpProviderSession.readMcpProviderSession(input.threadId); - // The attachments dir grant lets the agent read pasted files at - // the paths ProviderService injects into the turn text. It is a - // leaf directory holding only uploads. + // The attachments dir grant lets the agent read path-only uploads + // at the paths ProviderService injects into the turn text. It is + // a leaf directory holding only uploads. const runtime = yield* options.makeRuntime({ cwd, clientInfo: { name: "t3-code", version: "0.0.0" }, @@ -844,7 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const model = yield* applyAntigravityAcpModelSelection({ runtime, model: input.modelSelection?.model, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, mapError: (cause) => cause, }); yield* runtime.setMode(antigravityPermissionMode(input.runtimeMode)); @@ -1035,7 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const model = resolveAntigravityModel({ configOptions, model: requestedModel, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, }); const availableModels = antigravityModelOptions(configOptions); if (model && !availableModels.some((option) => option.value === model)) { @@ -1165,14 +1165,36 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const interruptTurn: Adapter["interruptTurn"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); + // A command that outlived its turn keeps running in the agent, and + // session/cancel only stops a prompt. The agent kills its background + // commands when its session closes, so Stop with nothing else running + // ends the session, as Claude's does. The next turn resumes it. + let idleWithCommands = false; yield* context.promptLock .withPermit( Effect.gen(function* () { + // Decided under the prompt lock so a turn cannot start in between. + if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { + context.stopped = true; + idleWithCommands = true; + return; + } yield* cancelRequests(context); yield* context.runtime.cancel; }), ) - .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); + .pipe( + Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), + // Once marked stopped the session must close, even if this call is + // interrupted, or it is left unreachable with its commands running. + Effect.ensuring( + Effect.suspend(() => + idleWithCommands + ? withThreadLock(threadId, stopContext(context)).pipe(Effect.ignore) + : Effect.void, + ), + ), + ); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index f99e6714ee3b..e3ba3b005edf 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics abortControllerInEffect:off - Tests hand-built AbortSignals to the SDK query stub to exercise cancellation. import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; @@ -72,6 +73,8 @@ class FakeClaudeQuery implements AsyncIterable { public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; public closeError: unknown | undefined; + /** Set by tests that exercise Claude's graceful interrupt. */ + public interrupt?: () => Promise; emit(message: SDKMessage): void { if (this.done) { @@ -3254,6 +3257,83 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("interruptTurn lets Claude abort the turn before closing the session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + const turnCompletedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + let closeCallsAtInterrupt: number | undefined; + harness.query.interrupt = async () => { + closeCallsAtInterrupt = harness.query.closeCalls; + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: false, + errors: ["Error: Request was aborted."], + session_id: "sdk-session", + uuid: "result-interrupted", + } as unknown as SDKMessage); + }; + + yield* adapter.interruptTurn(session.threadId); + + assert.equal(closeCallsAtInterrupt, 0); + assert.equal(harness.query.closeCalls, 1); + const [turnCompleted] = Array.from(yield* Fiber.join(turnCompletedFiber)); + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(turnCompleted.payload.state, "interrupted"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("interruptTurn closes the session when Claude never aborts the turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + harness.query.interrupt = () => new Promise(() => {}); + + const interruptFiber = yield* adapter.interruptTurn(session.threadId).pipe(Effect.forkChild); + yield* TestClock.adjust("3 seconds"); + yield* Fiber.join(interruptFiber); + + assert.equal(harness.query.closeCalls, 1); + assert.equal(yield* adapter.hasSession(session.threadId), false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps the session available when process close fails", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -6837,6 +6917,56 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("completed turns keep their ids but not the SDK messages", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + const completedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-1", + uuid: "assistant-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-1", + content: [{ type: "text", text: "Hi" }], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-1", + uuid: "result-1", + } as unknown as SDKMessage); + yield* Fiber.join(completedFiber); + + const snapshot = yield* adapter.readThread(session.threadId); + assert.deepEqual( + snapshot.turns.map((entry) => ({ id: String(entry.id), items: entry.items })), + [{ id: String(turn.turnId), items: [] }], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("rewinds Claude history when the fork omits retained system messages", () => { const forkCalls: Array>> = []; let firstTurnId = ""; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a056c2828590..2d540b9077ca 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -267,7 +267,6 @@ interface ClaudeTurnState { * steered instead (the queued message continues the same turn). */ readonly synthetic?: boolean; - readonly items: Array; readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; @@ -386,6 +385,8 @@ interface ClaudeTaskAgentState { * lifetime; oldest entries evict first. */ const PENDING_TASK_MODEL_CAP = 64; +/** How long Stop waits for Claude to abort a turn before killing the process. */ +const CLAUDE_INTERRUPT_GRACE = "3 seconds"; /** * Buffers a subagent snapshot's authoritative model under its @@ -422,10 +423,11 @@ interface ClaudeSessionContext { resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; - readonly turns: Array<{ - id: TurnId; - items: Array; - }>; + /** Completed turn ids, reported by readThread and trimmed on rollback. + * SDK messages are not kept: rollback reads Claude's own history through + * turnStartMessageIds, and a long-lived session would otherwise hold every + * message it ever produced. */ + readonly turns: Array<{ readonly id: TurnId }>; readonly inFlightTools: Map; readonly claudeTasks: Map; readonly taskAgents: Map; @@ -453,10 +455,14 @@ interface ClaudeSessionContext { lastThreadStartedId: string | undefined; /** Limits already announced for the running turn, keyed `window:resetsAt`. */ announcedUsageLimits: { turnId: string; keys: Set } | undefined; + /** Resolved by completeTurn while Stop waits for Claude to abort the turn. */ + interruptedTurnSettled: Deferred.Deferred | undefined; stopped: boolean; } interface ClaudeQueryRuntime extends AsyncIterable { + /** SDK Query.interrupt — present on real queries; optional for test doubles. */ + readonly interrupt?: () => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -2176,10 +2182,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } return { threadId, - turns: context.turns.map((turn) => ({ - id: turn.id, - items: [...turn.items], - })), + turns: context.turns.map((turn) => ({ id: turn.id, items: [] })), }; }); @@ -2808,10 +2811,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - context.turns.push({ - id: turnState.turnId, - items: [...turnState.items], - }); + context.turns.push({ id: turnState.turnId }); yield* emitThreadTokenUsage(context, usageSnapshot, { rawMethod: "claude/result", @@ -2842,6 +2842,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const updatedAt = yield* nowIso; context.turnState = undefined; + if (context.interruptedTurnSettled) { + yield* Deferred.succeed(context.interruptedTurnSettled, undefined); + } context.session = { ...context.session, status: "ready", @@ -3175,10 +3178,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - if (context.turnState) { - context.turnState.items.push(message.message); - } - for (const toolResult of toolResultBlocksFromUserMessage(message)) { const toolEntry = Array.from(context.inFlightTools.entries()).find( ([, tool]) => tool.itemId === toolResult.toolUseId, @@ -3378,7 +3377,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( turnId, startedAt, synthetic: true, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), @@ -3461,7 +3459,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( cwd: path.resolve(context.session.cwd ?? "."), }); } - context.turnState.items.push(message.message); if ( normalizeClaudeActiveTokenUsage( message.message.usage, @@ -5111,6 +5108,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, announcedUsageLimits: undefined, + interruptedTurnSettled: undefined, stopped: false, }; yield* Ref.set(contextRef, context); @@ -5259,7 +5257,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, - items: [], assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), @@ -5343,6 +5340,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); + yield* settleInterruptedTurn(context); // interrupt() can acknowledge while resumed background tasks keep the // CLI alive. Stop is a hard session boundary for Claude, so close the // query and let the SDK escalate to SIGKILL when graceful exit fails. @@ -5350,6 +5348,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); + // Lets Claude abort the running turn through its own path before Stop kills + // the process, so the prompt reaches the transcript. Killing a first turn + // before Claude writes it leaves a resume cursor for a session Claude never + // saved, and every later message fails with "No conversation found". + const settleInterruptedTurn = Effect.fn("settleInterruptedTurn")(function* ( + context: ClaudeSessionContext, + ) { + const interrupt = context.query.interrupt?.bind(context.query); + if (context.stopped || !context.turnState || !interrupt) return; + const settled = yield* Deferred.make(); + context.interruptedTurnSettled = settled; + yield* Effect.tryPromise(interrupt).pipe( + Effect.ignore, + Effect.andThen(Deferred.await(settled)), + Effect.timeoutOption(CLAUDE_INTERRUPT_GRACE), + ); + context.interruptedTurnSettled = undefined; + }); + const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* requireSession(threadId); @@ -5411,6 +5428,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) => { // SDK history helpers read process.env. Isolate the provider's home instead // of changing the server's environment while other providers are running. + // @effect-diagnostics-next-line runEffectInsideEffect:off - SDK callback runs outside the fiber; the spawn is self-contained const result = await Effect.runPromise( spawnAndCollect( process.execPath, @@ -5601,7 +5619,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( for (const result of results) { if (result._tag === "Failure") { - return yield* Effect.fail(result.failure); + return yield* result.failure; } } }); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ded1f0abdf6e..5f40b436a384 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,7 @@ import { type ClaudeSettings, type ModelCapabilities, type ServerProviderSlashCommand, + type ServerProviderResetCredits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -426,6 +427,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, + /** Banked resets for a subscription login, given the CLI version for the user agent. */ + resolveResetCredits?: (version: string) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -568,6 +571,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, }) : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + const resetCredits = + resolveResetCredits && + capabilities.subscriptionType && + !usageLimits.unavailable && + parsedVersion + ? yield* resolveResetCredits(parsedVersion) + : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -585,7 +595,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), - usageLimits, + usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9f464bdaa177..9aff859521fc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -225,7 +225,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); @@ -2383,6 +2383,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { method: "item/tool/requestUserInput", requestId: ApprovalRequestId.make("req-user-input-1"), payload: { + isBlocking: true, itemId: "item-user-input-1", threadId: "thread-1", turnId: "turn-1", @@ -3083,3 +3084,112 @@ usageLimitLayer("CodexAdapterLive usage limits", (it) => { }), ); }); + +it.effect("managed runtime rotation restarts app-server and resumes the same native thread", () => { + const runtimes: FakeCodexRuntime[] = []; + let revision = "first"; + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + return yield* makeCodexAdapter(decodeCodexSettings({}), { + resolveRuntime: Effect.sync(() => ({ + config: decodeCodexSettings({ + binaryPath: "/t3/tools/codex/0.155.1/bin/codex", + homePath: "/t3/caches/codex/home", + launchArgs: "-c 'model_provider=managed'", + }), + environment: { ACCESS_TOKEN: `dummy-${revision}` }, + revision, + })), + makeRuntime: (options) => { + const runtime = new FakeCodexRuntime(options); + runtime.startImpl.mockImplementation(() => + Promise.resolve({ + provider: ProviderDriverKind.make("codex"), + threadId: options.threadId, + runtimeMode: options.runtimeMode, + cwd: options.cwd, + status: "ready", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + resumeCursor: { threadId: "native-managed-thread" }, + }), + ); + runtimes.push(runtime); + return Effect.succeed(runtime); + }, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("managed-token-rotation"); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "first" }); + NodeAssert.equal(runtimes.length, 1); + revision = "rotated"; + yield* adapter.sendTurn({ threadId, input: "second" }); + NodeAssert.equal(runtimes.length, 2); + NodeAssert.equal(runtimes[0]?.closeImpl.mock.calls.length, 1); + NodeAssert.deepEqual(runtimes[1]?.options.resumeCursor, { threadId: "native-managed-thread" }); + NodeAssert.equal(runtimes[1]?.options.environment?.ACCESS_TOKEN, "dummy-rotated"); + NodeAssert.equal(runtimes[1]?.options.binaryPath, "/t3/tools/codex/0.155.1/bin/codex"); + }).pipe(Effect.provide(layer)); +}); + +it.effect("managed turn failures preserve the sharing-limit code for client notices", () => { + const factory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + return yield* makeCodexAdapter(decodeCodexSettings({}), { + makeRuntime: factory.factory, + resolveRuntime: Effect.succeed({ + config: decodeCodexSettings({}), + environment: {}, + revision: "managed", + }), + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ threadId: asThreadId("thread-1"), runtimeMode: "full-access" }); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + const notification = codexUsageLimitTurnFailed("managed-sharing-limit"); + yield* factory.lastRuntime!.emit({ + ...notification, + payload: { + threadId: "thread-1", + turn: { + id: "turn-limit", + items: [], + status: "failed", + error: { message: "subscription_sharing_usage_limit_exceeded", codexErrorInfo: "other" }, + }, + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.equal(events[0]?.type, "runtime.error"); + if (events[0]?.type === "runtime.error") { + NodeAssert.equal(events[0].payload.code, "subscription_sharing_usage_limit_exceeded"); + NodeAssert.match(events[0].payload.message, /ChatGPT usage limit/); + } + NodeAssert.equal(events[1]?.type, "turn.completed"); + if (events[1]?.type === "turn.completed") NodeAssert.equal(events[1].payload.state, "failed"); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0ecc9693ab04..64c7012c4a92 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -22,6 +22,7 @@ import { type ToolActivityNativeAppReference, type ToolActivitySource, type ProviderUserInputAnswers, + type ServerProviderModel, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, @@ -66,7 +67,6 @@ import { makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, - type CodexSessionRuntimeSendTurnInput, type CodexSessionRuntimeShape, } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -84,11 +84,14 @@ const isCodexSessionRuntimeThreadIdMissingError = Schema.is( ); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); +import { classifyCodexManagedError } from "../CodexManagedErrors.ts"; const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + /** The provider's model list; supplies model display names for runtime info. */ + readonly models?: Effect.Effect>; readonly makeRuntime?: ( options: CodexSessionRuntimeOptions, ) => Effect.Effect< @@ -96,6 +99,12 @@ export interface CodexAdapterLiveOptions { CodexSessionRuntimeError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope >; + readonly resolveRuntime?: Effect.Effect< + import("../CodexManagedRuntime.ts").CodexEffectiveRuntime, + import("@t3tools/contracts").ProviderSetupError, + Scope.Scope + >; + readonly onManagedConnectionRevoked?: Effect.Effect; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -106,6 +115,8 @@ interface CodexAdapterSessionContext { readonly runtime: CodexSessionRuntimeShape; readonly eventFiber: Fiber.Fiber; readonly turnTokenUsage: CodexTurnTokenUsageState; + readonly startInput: Parameters[0]; + readonly runtimeRevision?: string; stopped: boolean; } @@ -2267,8 +2278,28 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* Effect.suspend(() => stopSessionInternal(existing)); } + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + const resolved = options?.resolveRuntime + ? yield* options.resolveRuntime.pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: cause.detail, + }), + ), + ) + : undefined; + const effectiveConfig = resolved?.config ?? codexConfig; + const effectiveEnvironment = resolved?.environment ?? options?.environment; const serviceTier = - input.modelSelection?.instanceId === boundInstanceId + !resolved && input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); @@ -2276,10 +2307,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( threadId: input.threadId, providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), - binaryPath: codexConfig.binaryPath, - launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), - ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + ...(options?.models ? { models: options.models } : {}), + binaryPath: effectiveConfig.binaryPath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment), + ...(effectiveEnvironment ? { environment: effectiveEnvironment } : {}), + ...(effectiveConfig.homePath ? { homePath: effectiveConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), @@ -2292,7 +2324,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { environment: { ...McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + effectiveEnvironment ?? process.env, mcpSession, ), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), @@ -2314,11 +2346,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( // after the stop and often sparse, so keep the session's merged view of // it and read it when a turn fails on the limit. let rateLimits: CodexRateLimitSnapshot | undefined; - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); const createRuntime = options?.makeRuntime ?? makeCodexSessionRuntime; const runtime = yield* createRuntime(runtimeInput).pipe( Effect.provideService(Scope.Scope, sessionScope), @@ -2389,6 +2416,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return; } + const managedError = options?.resolveRuntime + ? classifyCodexManagedError(event.payload) + : undefined; + if (managedError?.revoke && options?.onManagedConnectionRevoked) + yield* options.onManagedConnectionRevoked; let usageLimitError: ProviderRuntimeEvent | undefined; let usageLimitMessage: string | undefined; if (event.method === "turn/completed") { @@ -2400,7 +2432,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( completedPayload?.turn.status === "failed" ? completedPayload.turn.error : undefined; - if (turnError?.codexErrorInfo === "usageLimitExceeded") { + if (turnError && managedError) { + usageLimitMessage = managedError.message; + usageLimitError = { + ...runtimeEventBase(event, event.threadId), + type: "runtime.error", + payload: { + message: managedError.message, + code: managedError.code, + class: "provider_error", + }, + }; + } else if (turnError?.codexErrorInfo === "usageLimitExceeded") { usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt); usageLimitError = { ...runtimeEventBase(event, event.threadId), @@ -2415,12 +2458,27 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } const mappedEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { + if (managedError && runtimeEvent.type === "runtime.error") + return { + ...runtimeEvent, + payload: { + ...runtimeEvent.payload, + message: managedError.message, + detail: managedError.message, + code: managedError.code, + }, + } satisfies ProviderRuntimeEvent; + if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) { return { ...runtimeEvent, payload: { ...runtimeEvent.payload, - ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}), + ...(managedError + ? { errorMessage: managedError.message } + : usageLimitMessage + ? { errorMessage: usageLimitMessage } + : {}), tokenUsage: completeCodexTurnTokenUsage( turnTokenUsage, String(runtimeEvent.turnId), @@ -2485,6 +2543,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( runtime, eventFiber, turnTokenUsage, + startInput: input, + ...(resolved ? { runtimeRevision: resolved.revision } : {}), stopped: false, }); sessionScopeTransferred = true; @@ -2526,13 +2586,34 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( { concurrency: 1 }, ); - const session = yield* requireSession(input.threadId); + let session = yield* requireSession(input.threadId); + if (options?.resolveRuntime) { + const next = yield* options.resolveRuntime.pipe( + Effect.scoped, + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: cause.detail, + }), + ), + ); + if (next.revision !== session.runtimeRevision) { + const previous = yield* session.runtime.getSession; + yield* startSession({ + ...session.startInput, + ...(previous.resumeCursor ? { resumeCursor: previous.resumeCursor } : {}), + }); + session = yield* requireSession(input.threadId); + } + } const reasoningEffort = input.modelSelection?.instanceId === boundInstanceId ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") : undefined; const serviceTier = - input.modelSelection?.instanceId === boundInstanceId + !options?.resolveRuntime && input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; return yield* session.runtime diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 2a9fb56c186a..5405169926f5 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -868,3 +868,76 @@ describe("CodexSessionRuntime collab integration", () => { ); } }); + +describe("CodexSessionRuntime compaction", () => { + it.effect("restores T3 context after the root thread compacts", () => + Effect.gen(function* () { + const compacted = (threadId: string) => ({ + method: "item/completed", + params: { + threadId, + turnId: `${threadId}-turn`, + completedAtMs: 0, + item: { type: "contextCompaction", id: `compaction-${threadId}` }, + }, + }); + const script = { + rootThreadId: ROOT, + recordRequests: true, + // A child's compaction must not inject into the root thread. + notifications: [compacted(CHILD_A), compacted(ROOT)], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-compaction-context"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + models: Effect.succeed([ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + ]), + }); + const completedFiber = yield* runtime.events.pipe( + Stream.filter((event) => event.method === "turn/completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep going", interactionMode: "default" }); + yield* Fiber.join(completedFiber); + + // The restore is awaited before later notifications, so it has landed. + const requests = readRecordedRequests(); + assert.lengthOf(requests, 1); + const [inject] = requests; + assert.isDefined(inject); + assert.equal(inject.method, "thread/inject_items"); + assert.equal(inject.params.threadId, ROOT); + const texts = ( + inject.params.items as ReadonlyArray<{ role: string; content: [{ text: string }] }> + ).map((item) => { + assert.equal(item.role, "developer"); + return item.content[0].text; + }); + assert.lengthOf(texts, 1); + assert.match( + texts[0] ?? "", + /^.*as GPT-5\.6 Sol \(model slug: gpt-5\.6-sol\).*<\/t3_code_runtime>$/s, + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0c7a40d9bd9e..2c67b6f144db 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -81,6 +81,11 @@ it("uses standard routing when the catalog has no default service tier", () => { name: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + name: "Ultrafast", + description: "The fastest available responses for latency-sensitive work.", + }, ], supportedReasoningEfforts: [], }); @@ -97,6 +102,11 @@ it("uses standard routing when the catalog has no default service tier", () => { label: "Fast", description: "1.5x speed, increased usage", }, + { + id: "ultrafast", + label: "Ultrafast", + description: "Even faster, more expensive", + }, ], currentValue: "default", }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index bb49100e25f8..a610a1350185 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -90,6 +90,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { const DEFAULT_SERVICE_TIER_ID = "default"; +/** Shorter copy for tiers whose catalog description wraps in the traits menu. */ +const SERVICE_TIER_DESCRIPTIONS: Readonly> = { + ultrafast: "Even faster, more expensive", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -115,6 +120,8 @@ export function codexPlanLabel(planType: string | null | undefined): string | un return "ChatGPT Pro 20x Subscription"; case "prolite": return "ChatGPT Pro 5x Subscription"; + case "promax": + return "ChatGPT Pro Max Subscription"; case "team": return "ChatGPT Team Subscription"; case "self_serve_business_prolite": @@ -195,12 +202,15 @@ export function mapCodexModelCapabilities( label: "Standard", ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), }, - ...serviceTiers.map((tier) => ({ - id: tier.id, - label: tier.name, - ...(tier.description ? { description: tier.description } : {}), - ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), - })), + ...serviceTiers.map((tier) => { + const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description; + return { + id: tier.id, + label: tier.name, + ...(description ? { description } : {}), + ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), + }; + }), ], currentValue: defaultServiceTier, }); @@ -343,8 +353,9 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { - name: "t3code_desktop", - title: "T3 Turbo Desktop", + // `name` is the client identity Codex reports upstream; keep upstream's value. + name: "T3 Code", + title: "T3 Turbo", version: packageJson.version, }, capabilities: { @@ -416,6 +427,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; }) { const { client, initialize } = yield* withCodexAppServerClient(input); @@ -441,31 +453,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun requestAllCodexModels(client), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. - client.request("account/rateLimits/read", undefined).pipe( - Effect.map((response): CodexRateLimitsProbe => ({ - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - })), - Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), - Effect.map( - Option.getOrElse((): CodexRateLimitsProbe => ({ - failure: "Codex did not answer the usage request.", - })), - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + input.skipNativeUsage + ? Effect.succeed(undefined) + : client.request("account/rateLimits/read", null).pipe( + Effect.map((response): CodexRateLimitsProbe => ({ + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + })), + Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), + Effect.map( + Option.getOrElse((): CodexRateLimitsProbe => ({ + failure: "Codex did not answer the usage request.", + })), + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), ), - ), - ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, - rateLimits, + ...(rateLimits ? { rateLimits } : {}), version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -567,12 +581,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; + readonly skipNativeUsage?: boolean; }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, + managedAuth?: ServerProvider["auth"], ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -606,6 +622,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, + ...(managedAuth ? { skipNativeUsage: true } : {}), }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), @@ -654,7 +671,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu } const snapshot = probeResult.success.value; - const accountStatus = accountProbeStatus(snapshot.account); + const accountStatus = managedAuth + ? { status: "ready" as const, auth: managedAuth, message: undefined } + : accountProbeStatus(snapshot.account); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) @@ -691,7 +710,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), - usageLimits, + ...(managedAuth ? {} : { usageLimits }), }, }); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index c79eae9ffcee..6382173a1df1 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,7 +9,10 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; +import { + buildCodexAdditionalContext, + buildCodexDeveloperInstructions, +} from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -90,25 +93,28 @@ describe("Codex thread history", () => { ); } - it.effect("keeps the count-based rollback API for older threads", () => + it.effect("surfaces Codex rejecting a revert of a legacy thread", () => Effect.gen(function* () { + const rejection = CodexErrors.CodexAppServerRequestError.invalidRequest( + "thread/revert only supports paginated threads", + ); const client: Parameters[0] = { - raw: { request: () => Effect.succeed({ thread: {} }) }, - request: ( - method: M, - params: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - NodeAssert.equal(method, "thread/rollback"); - NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 }); + raw: { + request: (method) => { + if (method === "thread/read") return Effect.succeed({ thread: {} }); + if (method === "thread/revert") return Effect.fail(rejection); + return Effect.die(`Unexpected raw request: ${method}`); + }, + }, + request: (method: M) => { + NodeAssert.equal(method, "thread/read"); return Effect.succeed({ - thread: { id: "legacy-thread", turns: [] }, + thread: { id: "legacy-thread", turns: [{ id: "turn-1", items: [] }] }, } as unknown as CodexRpc.ClientRequestResponsesByMethod[M]); }, }; - NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), { - threadId: "legacy-thread", - turns: [], - }); + const error = yield* Effect.flip(rollbackCodexThread(client, "legacy-thread", 1)); + NodeAssert.strictEqual(error, rejection); }), ); }); @@ -229,12 +235,13 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + developer_instructions: buildCodexDeveloperInstructions("plan"), }, }, + additionalContext: buildCodexAdditionalContext({ + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }); }); @@ -278,12 +285,13 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + developer_instructions: buildCodexDeveloperInstructions("default"), }, }, + additionalContext: buildCodexAdditionalContext({ + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }); }); @@ -300,9 +308,29 @@ describe("buildTurnStartParams", () => { const settings = params.collaborationMode?.settings; NodeAssert.equal(settings?.model, DEFAULT_MODEL); NodeAssert.equal(settings?.reasoning_effort, "medium"); - NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + NodeAssert.ok( + params.additionalContext?.t3_code_runtime?.value.includes(`as ${DEFAULT_MODEL} with medium`), + ); }); + it.effect("names the model by display name and slug in the runtime context", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + modelName: "GPT-5.3-Codex", + effort: "high", + interactionMode: "plan", + }); + + NodeAssert.match( + params.additionalContext?.t3_code_runtime?.value ?? "", + /as GPT-5\.3-Codex \(model slug: gpt-5\.3-codex\) with high reasoning effort/, + ); + }), + ); + it.effect("routes approvals to the auto reviewer in auto mode", () => Effect.gen(function* () { const params = yield* buildTurnStartParams({ @@ -557,99 +585,83 @@ describe("Codex MCP elicitation approvals", () => { }); describe("buildCodexDeveloperInstructions", () => { - it("appends runtime info after the mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - - NodeAssert.match(instructions, /^# Collaboration Mode: Default/); - NodeAssert.match(instructions, /T3 Turbo/); - NodeAssert.match(instructions, /Codex harness/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); - }); - - it("describes Markdown media support in the runtime context in both modes", () => { + it("keeps T3 context out of the mode prompt, which the model catalog can replace", () => { for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images and videos.*Markdown.*<\/runtime_info>/, - ); + const instructions = buildCodexDeveloperInstructions(mode); + NodeAssert.match(instructions, /^[\s\S]*<\/collaboration_mode>$/); + NodeAssert.doesNotMatch(instructions, /runtime_info|pull_request_linking|preview_|device_/); } }); +}); - it("includes runtime info alongside plan mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); +describe("buildCodexAdditionalContext", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + const runtimeValue = (context: ReturnType) => + context.t3_code_runtime?.value ?? ""; - NodeAssert.match(instructions, /^# Plan Mode/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + it("describes the harness, model, effort, and Markdown media support", () => { + const context = buildCodexAdditionalContext(runtime); + + NodeAssert.equal(context.t3_code_runtime?.kind, "application"); + NodeAssert.match(runtimeValue(context), /running in T3 Turbo/); + NodeAssert.match( + runtimeValue(context), + /.*Codex harness, as gpt-5\.3-codex with high reasoning effort.*embed images and videos.*Markdown.*<\/runtime_info>/, + ); }); it("varies with the model and effort of each turn", () => { - const first = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - const second = buildCodexDeveloperInstructions("default", { - model: "gpt-5.4", - reasoningEffort: "high", - }); - - NodeAssert.notEqual(first, second); + NodeAssert.notEqual( + runtimeValue( + buildCodexAdditionalContext({ model: "gpt-5.3-codex", reasoningEffort: "medium" }), + ), + runtimeValue(buildCodexAdditionalContext({ model: "gpt-5.4", reasoningEffort: "high" })), + ); }); it("flattens multiline metadata into single-line runtime info", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt\n5.3\ncodex", - reasoningEffort: " high\neffort ", - }); + const value = runtimeValue( + buildCodexAdditionalContext({ model: "gpt\n5.3\ncodex", reasoningEffort: " high\neffort " }), + ); - NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); - NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + NodeAssert.match(value, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(value, /[^<]*\n/); + }); + + it("keeps every entry under Codex's 1,000 token cap per entry", () => { + const context = buildCodexAdditionalContext(runtime, { browser: true, device: true }); + for (const entry of Object.values(context)) { + // Codex estimates 4 bytes per token and truncates the middle of longer values. + NodeAssert.ok(Buffer.byteLength(entry.value) < 4_000); + } }); }); -describe("T3 browser developer instructions", () => { +describe("T3 tool instructions", () => { const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; - it("prefers the product-native preview tools in both collaboration modes", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, true); - NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); - } + it("prefers the product-native preview tools when they are attached", () => { + const tools = buildCodexAdditionalContext(runtime, true).t3_code_tools?.value ?? ""; + NodeAssert.match(tools, /t3-code/); + NodeAssert.match(tools, /preview_status/); + NodeAssert.match(tools, /preview_open/); + NodeAssert.match(tools, /Do not switch to global browser skills/); + NodeAssert.doesNotMatch(tools, /device_open/); }); - it("omits the browser block entirely when the preview tools are not attached", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, false); - NodeAssert.doesNotMatch(instructions, /preview_status/); - NodeAssert.doesNotMatch(instructions, /preview_open/); - NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); - // Steering away from other browser automation must go with the tools; - // keeping it would leave the model talked out of its only option. - NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); - // The rest of the collaboration mode is untouched. - NodeAssert.match(instructions, //); - NodeAssert.match(instructions, /<\/collaboration_mode>/); - } + it("describes device tools only when the credential grants them", () => { + const tools = + buildCodexAdditionalContext(runtime, { browser: false, device: true }).t3_code_tools?.value ?? + ""; + NodeAssert.match(tools, /device_open/); + NodeAssert.doesNotMatch(tools, /preview_open/); }); - it("tracks the turn's MCP configuration rather than defaulting to on", () => { - NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); - NodeAssert.doesNotMatch( - buildCodexDeveloperInstructions("default", runtime, false), - /preview_open/, - ); + it("omits the tool entry entirely when no tools are attached", () => { + // Steering away from other browser automation must go with the tools; + // keeping it would leave the model talked out of its only option. + const context = buildCodexAdditionalContext(runtime, false); + NodeAssert.deepStrictEqual(Object.keys(context), ["t3_code_runtime"]); }); }); @@ -680,6 +692,7 @@ function makeThreadStartedNotification( id: threadId, modelProvider: "openai", preview: "", + projectId: null, sessionId: threadId, source, status: { type: "idle" as const }, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 674d23327b65..f30baf011e8a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -14,6 +14,7 @@ import { type ProviderTurnStartResult, type ProviderUserInputAnswers, RuntimeMode, + type ServerProviderModel, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -40,6 +41,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { + buildCodexAdditionalContext, buildCodexDeveloperInstructions, type T3CodeToolAvailability, } from "../CodexDeveloperInstructions.ts"; @@ -119,7 +121,7 @@ const McpElicitationFormField = Schema.Struct({ type: Schema.optionalKey(NullableMcpElicitationString), title: Schema.optionalKey(NullableMcpElicitationString), description: Schema.optionalKey(NullableMcpElicitationString), - default: Schema.optionalKey(Schema.Unknown), + default: Schema.optionalKey(Schema.Json), enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), oneOf: Schema.optionalKey( @@ -141,10 +143,13 @@ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); const isMcpElicitationForm = Schema.is(McpElicitationForm); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated -// `V2TurnStartParams` schema includes `collaborationMode` directly. +// `V2TurnStartParams` schema includes its experimental fields directly. const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), + additionalContext: Schema.optionalKey( + Schema.Record(Schema.String, EffectCodexSchema.V2TurnStartParams__AdditionalContextEntry), + ), }), ); const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( @@ -163,8 +168,7 @@ export type CodexTurnStartParamsWithCollaborationMode = export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; type CodexServiceTier = NonNullable; type CodexThreadItem = - | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] - | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; + EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number]; export interface CodexSessionRuntimeOptions { readonly threadId: ThreadId; @@ -179,6 +183,8 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** The provider's model list; supplies the display name for runtime info. */ + readonly models?: Effect.Effect>; /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ readonly mcpCapabilities?: ReadonlySet; } @@ -442,7 +448,7 @@ export function toMcpElicitationResponse( ? "always" : undefined; const form = mcpElicitationFormFields(payload); - const content: Record = {}; + const content: Record = {}; for (const [key, field] of Object.entries(form?.properties ?? {})) { const options = mcpElicitationFieldOptions(field); @@ -580,28 +586,31 @@ function runtimeModeToTurnSandboxPolicy( } } -function buildCodexCollaborationMode(input: { +function buildCodexTurnInstructions(input: { readonly interactionMode?: ProviderInteractionMode; readonly model?: string; + readonly modelName?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; -}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { +}): Pick { if (input.interactionMode === undefined) { - return undefined; + return {}; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; const reasoningEffort = input.effort ?? "medium"; return { - mode: input.interactionMode, - settings: { - model, - reasoning_effort: reasoningEffort, - developer_instructions: buildCodexDeveloperInstructions( - input.interactionMode, - { model, reasoningEffort }, - input.browserToolsAvailable ?? true, - ), + collaborationMode: { + mode: input.interactionMode, + settings: { + model, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode), + }, }, + additionalContext: buildCodexAdditionalContext( + { model, modelName: input.modelName, reasoningEffort }, + input.browserToolsAvailable ?? true, + ), }; } @@ -618,6 +627,8 @@ export function buildTurnStartParams(input: { readonly path: string; }>; readonly model?: string; + /** Display name of `model`, for runtime info. */ + readonly modelName?: string; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; @@ -639,9 +650,10 @@ export function buildTurnStartParams(input: { } const config = runtimeModeToThreadConfig(input.runtimeMode); - const collaborationMode = buildCodexCollaborationMode({ + const turnInstructions = buildCodexTurnInstructions({ ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), ...(input.model ? { model: input.model } : {}), + ...(input.modelName ? { modelName: input.modelName } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, }); @@ -655,7 +667,7 @@ export function buildTurnStartParams(input: { ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), - ...(collaborationMode ? { collaborationMode } : {}), + ...turnInstructions, }).pipe( Effect.mapError((cause) => CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( @@ -1185,7 +1197,7 @@ function updateSession( } function parseThreadSnapshot( - response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse, + response: EffectCodexSchema.V2ThreadReadResponse, ): CodexThreadSnapshot { return { threadId: response.thread.id, @@ -1273,11 +1285,8 @@ export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* ( threadId: string, numTurns: number, ): Effect.fn.Return { - if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { - return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns })); - } - // Paginated threads replace history at a turn boundary instead of supporting - // the legacy count-based rollback endpoint. + // Codex replaces history at a turn boundary. It rejects threads that still + // use legacy history, which have no rollback API since Codex 0.156. const snapshot = yield* readCodexThread(client, threadId); const retainedCount = Math.max(0, snapshot.turns.length - numTurns); const firstRemoved = snapshot.turns[retainedCount]; @@ -1309,6 +1318,9 @@ export const makeCodexSessionRuntime = ( const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); const closedRef = yield* Ref.make(false); + /** The `additionalContext` of the latest `turn/start`, restored after compaction. */ + const lastAdditionalContextRef = + yield* Ref.make(undefined); // `~` is not shell-expanded when env vars are set via // `child_process.spawn`; `expandHomePath` lets a configured @@ -1522,7 +1534,7 @@ export const makeCodexSessionRuntime = ( } }), ), - Effect.catch(() => Effect.void), + Effect.ignore, Effect.forkIn(runtimeScope), ); }); @@ -1860,6 +1872,35 @@ export const makeCodexSessionRuntime = ( } }); + /** + * Compaction rebuilds history from user messages and Codex's own context, + * which drops our `additionalContext` messages. Codex only resends an + * entry when its value changes, so without this the T3 context would stay + * lost until the model or effort changed. Awaited so the context is back + * before later notifications from the same turn are handled. Drop this if + * Codex enables its `retain_client_developer_messages` feature by default. + */ + const restoreAdditionalContext = (threadId: string) => + Effect.gen(function* () { + const context = yield* Ref.get(lastAdditionalContextRef); + if (!context) return; + yield* client.request("thread/inject_items", { + threadId, + items: Object.entries(context).map(([key, entry]) => ({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: `<${key}>${entry.value}` }], + })), + }); + }).pipe( + Effect.timeout("10 seconds"), + Effect.catch((cause) => + Effect.logWarning("Failed to restore Codex additional context after compaction.", { + cause, + }), + ), + ); + const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { const isMemoryConsolidationNotification = @@ -1946,6 +1987,14 @@ export const makeCodexSessionRuntime = ( return; } + if ( + notification.method === "item/completed" && + notification.params.item.type === "contextCompaction" && + notification.params.threadId === suppressRootId + ) { + yield* restoreAdditionalContext(notification.params.threadId); + } + let requestId: ApprovalRequestId | undefined; let requestKind: ProviderRequestKind | undefined; let turnId = childParentTurnId ?? route.turnId; @@ -2513,12 +2562,15 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const models = options.models ? yield* options.models : []; + const modelName = models.find((model) => model.slug === normalizedModel)?.name; const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), + ...(modelName ? { modelName } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), @@ -2530,6 +2582,7 @@ export const makeCodexSessionRuntime = ( options.mcpCapabilities, ), }); + yield* Ref.set(lastAdditionalContextRef, params.additionalContext); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 8a7560fe0e71..a3ee27addc86 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); @@ -987,21 +991,21 @@ describe("Cursor usage limits", () => { { id: "totalPercentUsed", kind: "monthly", - label: "Monthly", + label: "Overall", usedPercent: 72.4, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "autoPercentUsed", kind: "monthly", - label: "Monthly · Auto", + label: "Cursor Models", usedPercent: 69.5, resetsAt: "2026-09-20T03:53:06.000Z", }, { id: "apiPercentUsed", kind: "monthly", - label: "Monthly · API", + label: "Other Models", usedPercent: 100, resetsAt: "2026-09-20T03:53:06.000Z", }, @@ -1015,10 +1019,10 @@ describe("Cursor usage limits", () => { ); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); expect( cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); }); it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { @@ -1075,6 +1079,10 @@ describe("Cursor usage limits", () => { AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, ).pipe( Effect.provideService(HostProcessPlatform, platform), Effect.provideService( @@ -1104,6 +1112,71 @@ describe("Cursor usage limits", () => { } }); + it("reads the default macOS Cursor login from Keychain for limits", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }); + + it("reports a Keychain initialization failure without failing the provider refresh", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }); + + it("does not read Keychain or send its token to a custom endpoint", async () => { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ] as const) { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for a custom endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); + } + }); + it("reports failed requests without exposing credentials or response bodies", async () => { const limits = await runNode( readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 7974f27d8210..5bb770ab1154 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..fb17d6839fda 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -363,6 +363,66 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("retires a crashed process so a deliberate retry can resume", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-crash-recovery"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-crash-recovery-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapper = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_CRASH_PROMPT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapper); + const exited = + yield* Deferred.make>(); + const events = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "session.exited" + ? Deferred.succeed(exited, event).pipe(Effect.asVoid) + : Effect.void, + ).pipe(Effect.forkChild); + const input = { + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access" as const, + }; + const session = yield* adapter.startSession(input); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "crash now", attachments: [] }), + ); + assert.isDefined(failure); + assert.isFalse(yield* adapter.hasSession(threadId)); + const retryDuringTeardown = yield* Effect.flip( + adapter.sendTurn({ threadId, input: "retry during teardown", attachments: [] }), + ); + assert.equal(retryDuringTeardown._tag, "ProviderAdapterSessionNotFoundError"); + assert.equal((yield* Deferred.await(exited)).payload.exitKind, "error"); + assert.deepStrictEqual(yield* adapter.listSessions(), []); + yield* Fiber.interrupt(events); + yield* adapter.startSession({ ...input, resumeCursor: session.resumeCursor }); + const turn = yield* adapter.sendTurn({ threadId, input: "retry now", attachments: [] }); + assert.equal(turn.threadId, threadId); + yield* adapter.stopSession(threadId); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + assert.equal(requests.filter((request) => request.method === "session/new").length, 1); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + const resumes = requests.filter((request) => request.method === "session/load"); + assert.equal(resumes.length, 1); + assert.deepStrictEqual(resumes[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-mock-thread"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index bec0fc39faa0..63ff71eca257 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -175,6 +175,7 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + terminated: boolean; /** Live monitor/shell identities and their originating turns. */ readonly backgroundTasks: Map; } @@ -345,6 +346,7 @@ export function grokPromptSettlementBelongsToContext(input: { export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { return Effect.gen(function* () { + const ownerScope = yield* Effect.scope; const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -776,46 +778,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); - const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")( - function* (ctx: GrokSessionContext) { - while (true) { - if (ctx.stopped) { - return; - } - const turnId = ctx.livenessTurnId; - if ( - turnId === undefined || - ctx.interruptedTurnIds.has(turnId) || - !isLiveTurn(ctx, turnId) || - hasLivenessPause(ctx) - ) { - yield* Queue.take(ctx.livenessSignals); - continue; - } + const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")(function* ( + ctx: GrokSessionContext, + ) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } - const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; - if (lastActivityAtNanos === undefined) { - yield* Queue.take(ctx.livenessSignals); - continue; - } - const nowNanos = yield* Clock.monotonicTimeNanos; - const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); - if (remainingNanos <= 0n) { - yield* settleStalledTurn(ctx, turnId); - continue; - } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } - const wakeReason = yield* Effect.raceFirst( - Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), - Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), - ); - if (wakeReason === "timeout") { - yield* settleStalledTurn(ctx, turnId); - } + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); } - }, - Effect.catch(() => Effect.void), - ); + } + }, Effect.ignore()); const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { @@ -923,7 +924,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); - if (!ctx || ctx.stopped) { + if (!ctx || ctx.stopped || ctx.terminated) { return Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), ); @@ -947,7 +948,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ...(yield* makeEventStamp()), provider: PROVIDER, threadId: ctx.threadId, - payload: { exitKind: "graceful" }, + payload: { exitKind: ctx.terminated ? "error" : "graceful" }, }); }); @@ -1317,12 +1318,35 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + terminated: false, backgroundTasks: new Map(), }; const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { + if (event._tag === "ConnectionTerminated") { + ctx.terminated = true; + yield* withThreadLock( + ctx.threadId, + Effect.gen(function* () { + if (sessions.get(ctx.threadId) !== ctx) return; + if (ctx.activeTurnId) { + yield* settlePromptInFlight( + ctx.threadId, + ctx.activeTurnId, + ctx.acpSessionId, + { + errorMessage: "Grok connection terminated.", + settleAllPrompts: true, + }, + ); + } + yield* stopSessionInternal(ctx); + }), + ).pipe(Effect.forkIn(ownerScope)); + return; + } if (event._tag === "EventStreamBarrier") { yield* Deferred.succeed(event.acknowledge, undefined); return; @@ -2008,7 +2032,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte errorMessage: errorMessage ?? "Grok prompt request failed.", }), ); - }).pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.ignore), ), ); }); @@ -2166,12 +2190,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const listSessions: GrokAdapterShape["listSessions"] = () => - Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + Effect.sync(() => + Array.from(sessions.values()) + .filter((c) => !c.terminated) + .map((c) => ({ ...c.session })), + ); const hasSession: GrokAdapterShape["hasSession"] = (threadId) => Effect.sync(() => { const c = sessions.get(threadId); - return c !== undefined && !c.stopped; + return c !== undefined && !c.stopped && !c.terminated; }); const stopAll: GrokAdapterShape["stopAll"] = () => diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 495feaf8d61b..a51b124aadf5 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -19,7 +19,7 @@ import { parseGrokModelsCliOutput, } from "./GrokProvider.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts"; +import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -564,7 +564,10 @@ describe("Grok usage limits", () => { for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { const limits = grokUsageResponseToLimits(response, checkedAt); expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); + // Nothing metered yet, which xAI reports by omitting the field until + // usage registers. Marking it `unsupported` would drop the account from + // the Limits view for good; leaving the marker off keeps it listed. + expect(limits.unavailable).toBeUndefined(); } expect( grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, @@ -583,7 +586,7 @@ describe("Grok usage limits", () => { }); }); -it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { +it.layer(NodeServices.layer)("readGrokAccount", (it) => { it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -592,7 +595,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { NodePath.join(directory, "auth.json"), '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', ); - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ GROK_HOME: directory, HOME: "/unrelated-home", }).pipe( @@ -618,7 +621,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { it.effect( "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", () => - readGrokUsageLimits({ + readGrokAccount({ GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', }).pipe( Effect.provideService( @@ -642,7 +645,9 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { ); }), ), - Effect.tap((limits) => Effect.sync(() => expect(limits.windows[0]?.usedPercent).toBe(12))), + Effect.tap(({ usageLimits }) => + Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), + ), ), ); @@ -681,7 +686,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { GROK_CONFIG_PATH: "/custom-config.toml", }, ]) { - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ HOME: "/definitely/not/a/grok-home", ...environment, }).pipe( @@ -703,7 +708,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectoryScoped(); const client = HttpClient.make(() => Effect.die("must not request without credentials")); - const missing = yield* readGrokUsageLimits({ HOME: directory }).pipe( + const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( Effect.provideService(HttpClient.HttpClient, client), ); expect(missing.unavailable?.reason).toBe("unsupported"); @@ -711,7 +716,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { "private-token-invalid-json", '{"https://accounts.x.ai/sign-in":{"key":42}}', ]) { - const malformed = yield* readGrokUsageLimits({ + const { usageLimits: malformed } = yield* readGrokAccount({ GROK_HOME: directory, GROK_AUTH: contents, }).pipe(Effect.provideService(HttpClient.HttpClient, client)); @@ -732,7 +737,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { '[grok_com_config]\nissuer = "https://custom.example"', 'endpoints.proxy = "https://custom.example"', ]) { - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', }).pipe( Effect.provideService( @@ -755,15 +760,16 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { }), ); - it.effect("sanitizes HTTP failures and malformed billing responses", () => + it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => Effect.gen(function* () { for (const response of [ new Response("private response", { status: 401 }), Response.json({ config: { creditUsagePercent: "private-value" } }), ]) { - const limits = yield* readGrokUsageLimits({ + const { email, usageLimits: limits } = yield* readGrokAccount({ HOME: "/definitely/not/a/grok-home", - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', + GROK_AUTH: + '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', }).pipe( Effect.provideService( HttpClient.HttpClient, @@ -772,6 +778,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { ), ), ); + expect(email).toBe("someone@example.com"); expect(limits.windows).toEqual([]); expect(limits.unavailable).toEqual({ reason: "probeFailed", @@ -781,3 +788,58 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { }), ); }); + +it.layer(NodeServices.layer)("readGrokAccount email", (it) => { + it.effect("names the account whose limits were read, and nothing for other auth", () => + Effect.gen(function* () { + const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; + const cases = [ + [ + { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, + "Someone@Example.com", + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, + }, + "current@example.com", + ], + [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, + XAI_API_KEY: "api-key", + }, + undefined, + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, + }, + undefined, + ], + [{ GROK_AUTH: "not-json" }, undefined], + ] as const; + for (const [environment, expected] of cases) { + const { email, usageLimits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + ...environment, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 25 } }), + ), + ), + ), + ), + ); + expect(email).toBe(expected); + if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..40d034089ae9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -578,7 +579,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); @@ -781,6 +782,56 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect( + "stopSession completes after sendTurn is interrupted before the prompt is submitted", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupted-before-submit"); + runtimeMock.state.createdSessionIds.push("ses_interrupted_before_submit"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const baseClock = yield* Clock.clockWith(Effect.succeed); + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const gatedClock: Clock.Clock = { + ...baseClock, + currentTimeMillisUnsafe: () => baseClock.currentTimeMillisUnsafe(), + currentTimeMillis: Effect.suspend(() => { + calls += 1; + return calls === 2 + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(baseClock.currentTimeMillis), + ) + : baseClock.currentTimeMillis; + }), + }; + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "This prompt must not be submitted", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.provideService(Clock.Clock, gatedClock), Effect.exit, Effect.forkChild); + yield* Deferred.await(reached); + yield* Fiber.interrupt(sendFiber); + + const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit); + NodeAssert.equal(Exit.isSuccess(stopExit), true); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("aborts a held teardown request before closing the session scope", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..34ebd1b544fb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -822,7 +822,7 @@ const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* .pipe( Effect.catchIf( (cause) => isOpenCodeNotFound(cause), - () => Effect.void, + () => Effect.undefined, ), Effect.result, ); @@ -1097,7 +1097,7 @@ export function makeOpenCodeAdapter( readonly observedAt: string; readonly event: Record; }, - ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + ) => writeNativeEvent(threadId, event).pipe(Effect.ignoreCause); const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* ( context: OpenCodeSessionContext, @@ -1262,7 +1262,7 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.pendingIdleReconciliation === pending) { @@ -1492,7 +1492,7 @@ export function makeOpenCodeAdapter( } yield* failPromptAdmissionRecovery(context, promptAdmission); }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { delete promptAdmission.recoveryFiber; @@ -1699,7 +1699,7 @@ export function makeOpenCodeAdapter( }), Effect.catchIf( (cause) => isOpenCodeNotFound(cause), - () => Effect.succeed(undefined), + () => Effect.undefined, ), ); let sessionId: string | undefined = candidateSessionId; @@ -2044,7 +2044,7 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.requestRelationRetries.get(requestId) === retry) { @@ -2163,7 +2163,7 @@ export function makeOpenCodeAdapter( return; } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.pendingRequestRecovery === recovery) { @@ -2730,6 +2730,7 @@ export function makeOpenCodeAdapter( // the scope closes (explicit stop, unexpected exit, or layer // shutdown) and cancels the in-flight `event.subscribe` fetch so // the async iterable unwinds cleanly. + // @effect-diagnostics-next-line abortControllerInEffect:off - aborted by a scope finalizer to cancel the SDK's event.subscribe fetch const eventsAbortController = new AbortController(); let lastStreamError: unknown; let warnedAboutDisconnect = false; @@ -3530,7 +3531,18 @@ export function makeOpenCodeAdapter( ? { resumeCursor: context.session.resumeCursor } : {}), }; - }), + }).pipe( + // stopSession waits on submissionSettled; an interrupted sendTurn + // must not leave it pending. + Effect.ensuring( + Effect.suspend(() => { + const admission = context.promptAdmission; + return admission + ? Deferred.succeed(admission.submissionSettled, undefined).pipe(Effect.ignore) + : Effect.void; + }), + ), + ), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b50a51f6cb31..55e159197e6b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,4 +1,5 @@ import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; @@ -71,6 +72,10 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal( + limits.credentialFingerprint, + NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"), + ); NodeAssert.deepEqual( limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ kind, @@ -139,6 +144,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage Effect.provide(NodeServices.layer), ); NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.equal(limits.credentialFingerprint, undefined); NodeAssert.deepEqual(limits.windows, []); } }), diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index bb8ff7835c8f..6a24b49dc930 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -1,15 +1,21 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, + ThreadId, type ServerProvider, } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; +import * as ProviderAuthFlow from "../ProviderAuthFlow.ts"; + import type * as ClaudeAdapter from "../Services/ClaudeAdapter.ts"; import type * as CodexAdapter from "../Services/CodexAdapter.ts"; import type * as CursorAdapter from "../Services/CursorAdapter.ts"; @@ -185,3 +191,95 @@ it.layer(layer)("ProviderAdapterRegistryLive", (it) => { ]); })); }); + +it.effect("blocks shared credential session startup and preserves guarded adapter identity", () => + Effect.gen(function* () { + const target = fakeInstances[0]!; + const peer = fakeInstances[1]!; + const auth = yield* ProviderAuthFlow.make({ + instanceId: target.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([ + { id: "browser", name: "Browser", description: null, type: "agent" }, + ]), + authenticate: () => Effect.never, + logout: Effect.void, + }); + const peerAuth = yield* ProviderAuthFlow.make({ + instanceId: peer.instanceId, + credentialBinding: { owner: "t3", key: "shared-auth" }, + methods: Effect.succeed([]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const session = { + threadId: ThreadId.make("new-session"), + provider: peer.driverKind, + providerInstanceId: peer.instanceId, + status: "ready" as const, + runtimeMode: "approval-required" as const, + createdAt: "2026-09-21T00:00:00.000Z", + updatedAt: "2026-09-21T00:00:00.000Z", + }; + const start = vi.fn(() => Effect.succeed(session)); + const instances = [ + { ...target, auth }, + { ...peer, auth: peerAuth, adapter: { ...peer.adapter, startSession: start } }, + ]; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry.pipe( + Effect.provide( + ProviderAdapterRegistryLayer.ProviderAdapterRegistryLive.pipe( + Layer.provide( + Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (id) => + Effect.succeed(instances.find((instance) => instance.instanceId === id)), + listInstances: Effect.succeed(instances), + }), + ), + ), + ), + ); + const guarded = yield* registry.getByInstance(peer.instanceId); + assert.strictEqual(yield* registry.getByInstance(peer.instanceId), guarded); + const flow = yield* auth.start("owner"); + const error = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterValidationError"); + assert.strictEqual(start.mock.calls.length, 0); + yield* auth.cancel("owner", flow.flowId!); + assert.deepStrictEqual( + yield* guarded.startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }), + session, + ); + assert.strictEqual(start.mock.calls.length, 1); + const entered = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + start.mockImplementation(() => + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe(Effect.ensuring(Deferred.succeed(stopped, undefined))), + ); + const startup = yield* guarded + .startSession({ + threadId: session.threadId, + providerInstanceId: peer.instanceId, + runtimeMode: "approval-required", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(entered); + // Signing out through another instance must drain its peer's startup too. + yield* auth.logout(Effect.void); + yield* Deferred.await(stopped); + assert.strictEqual(Exit.isFailure(yield* Fiber.await(startup)), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 9e8e3c5d1f90..5d70ee0d3d75 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -15,19 +15,89 @@ * * @module ProviderAdapterRegistryLive */ -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, ProviderSetupError, type ProviderSession } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; -import { ProviderUnsupportedError } from "../Errors.ts"; +import { + ProviderUnsupportedError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, } from "../Services/ProviderAdapterRegistry.ts"; +import type { ProviderInstance } from "../ProviderDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const isSetupError = Schema.is(ProviderSetupError); + const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* () { const registry = yield* ProviderInstanceRegistry; + // Stable identity keeps ProviderService's event subscriptions attached once. + const guarded = new WeakMap>(); + const guard = (instance: ProviderInstance) => { + const auth = instance.auth; + if (!auth || (!auth.withAccess && !auth.isChangingCredentials && !auth.credentialBinding)) + return instance.adapter; + const cached = guarded.get(instance); + if (cached) return cached; + const adapter: ProviderAdapterShape = { + ...instance.adapter, + startSession: (input) => { + const start = Effect.gen(function* () { + const binding = auth.credentialBinding; + const related = binding + ? (yield* registry.listInstances).filter( + (peer) => + peer.auth?.credentialBinding?.key === binding.key && + peer.auth.credentialBinding.owner === binding.owner, + ) + : [instance]; + for (const peer of related) { + if (peer.auth?.isChangingCredentials && (yield* peer.auth.isChangingCredentials)) { + return yield* new ProviderSetupError({ + instanceId: instance.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + } + } + let admitted: Effect.Effect< + ProviderSession, + ProviderAdapterError | ProviderSetupError, + Scope.Scope + > = instance.adapter.startSession(input); + // Every shared owner holds the startup scope. A credential change + // interrupts admitted startup before it can escape the session drain. + for (const peer of related) { + if (peer.auth?.withAccess) admitted = peer.auth.withAccess(admitted); + } + return yield* Effect.scoped(admitted); + }); + // Adapters own established session lifetimes. This scope guards startup; + // ProviderAuthService drains routed sessions before changing credentials. + return start.pipe( + Effect.mapError((cause) => + isSetupError(cause) + ? new ProviderAdapterValidationError({ + provider: instance.driverKind, + operation: "startSession", + issue: cause.detail, + }) + : cause, + ), + ); + }, + }; + guarded.set(instance, adapter); + return adapter; + }; const getByInstance: ProviderAdapterRegistryShape["getByInstance"] = (instanceId) => registry.getInstance(instanceId).pipe( @@ -38,7 +108,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun provider: instanceId, }), ) - : Effect.succeed(instance.adapter), + : Effect.succeed(guard(instance)), ), ); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.test.ts b/apps/server/src/provider/Layers/ProviderAuthService.test.ts index fb961b710486..8430ad3e1319 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.test.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.test.ts @@ -112,6 +112,14 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* directoryError?: ProviderSessionDirectoryPersistenceError; stopError?: ProviderServiceError; logoutError?: ProviderSetupError; + sharedCredentials?: boolean; + sharedBusy?: boolean; + sharedBusyEffect?: Effect.Effect; + responds?: boolean; + beforeLogout?: Effect.Effect; + beforeStop?: Effect.Effect; + beforeListSessions?: Effect.Effect; + onLookup?: Effect.Effect; } = {}, ) { const actions: string[] = []; @@ -138,6 +146,18 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }); const auth: ProviderAuthController = { + ...(input.sharedCredentials + ? { credentialBinding: { owner: "provider" as const, key: "shared" } } + : {}), + ...(input.responds + ? { + respond: Effect.fn(function* (ownerSessionId, request) { + yield* checkOwner(ownerSessionId, request.flowId, "respond"); + actions.push(`respond:${request.response.type}`); + return state; + }), + } + : {}), start: Effect.fn(function* (ownerSessionId, stopSessions) { gateClosed = true; actions.push("close-gate"); @@ -169,6 +189,7 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push("close-gate"); yield* stopSessions; if (input.logoutError) return yield* input.logoutError; + yield* input.beforeLogout ?? Effect.void; actions.push("native-logout"); state = idle; flowOwner = undefined; @@ -181,13 +202,34 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* const instances = [ makeInstance({ instanceId, enabled: input.enabled ?? true, auth }), makeInstance({ instanceId: unsupportedInstanceId, enabled: true }), + ...(input.sharedCredentials + ? [ + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...auth, + isChangingCredentials: + input.sharedBusyEffect ?? Effect.succeed(input.sharedBusy ?? false), + invalidate: Effect.sync(() => { + actions.push("invalidate-shared"); + }), + }, + }), + ] + : []), ]; const service = yield* makeProviderAuthService.pipe( Effect.provide( Layer.mergeAll( Layer.mock(ProviderInstanceRegistry)({ getInstance: (id) => - Effect.succeed(instances.find((instance) => instance.instanceId === id)), + Effect.gen(function* () { + const found = instances.find((instance) => instance.instanceId === id); + yield* input.onLookup ?? Effect.void; + return found; + }), + listInstances: Effect.succeed(instances), subscribeChanges: PubSub.subscribe(registryChanges), }), Layer.mock(ProviderSessionDirectory)({ @@ -202,26 +244,44 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* }), Layer.mock(ProviderService)({ listSessions: () => - Effect.sync(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push("list-sessions"); + yield* input.beforeListSessions ?? Effect.void; return [...sessions.values()]; }), stopSession: ({ threadId }) => - Effect.suspend(() => { + Effect.gen(function* () { assert.isTrue(gateClosed); actions.push(`stop:${threadId}`); - if (input.stopError) return Effect.fail(input.stopError); + yield* input.beforeStop ?? Effect.void; + if (input.stopError) return yield* input.stopError; sessions.delete(threadId); const binding = bindings.get(threadId); if (binding) bindings.set(threadId, { ...binding, status: "stopped" }); - return Effect.void; }), }), ), ), ); - return { service, actions, sessions, bindings }; + return { + service, + actions, + sessions, + bindings, + auth, + addInstance: (instance: ProviderInstance) => instances.push(instance), + replaceInstance: (replacement: ProviderInstance) => { + const index = instances.findIndex( + (instance) => instance.instanceId === replacement.instanceId, + ); + assert.isAtLeast(index, 0); + instances[index] = replacement; + }, + replaceAuth: (next: ProviderAuthController) => { + instances[0] = makeInstance({ instanceId, enabled: input.enabled ?? true, auth: next }); + }, + }; }); const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")( @@ -305,6 +365,56 @@ const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* }); describe("ProviderAuthService", () => { + it.effect( + "stops sessions sharing credentials and invalidates their processes before logout", + () => + Effect.gen(function* () { + const { service, actions, sessions } = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target"), + makeSession("shared", otherInstanceId), + makeSession("unrelated", unsupportedInstanceId), + ], + }); + yield* service.logout({ instanceId }); + assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("unrelated")]); + assert.isBelow(actions.indexOf("stop:shared"), actions.indexOf("invalidate-shared")); + assert.isBelow(actions.indexOf("invalidate-shared"), actions.indexOf("native-logout")); + }), + ); + it.effect("rejects overlapping changes to a shared sign-in", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ + sharedCredentials: true, + sharedBusy: true, + }); + for (const task of [service.start({ instanceId }, owner), service.logout({ instanceId })]) { + const error = yield* task.pipe(Effect.flip); + assert.include(error.detail, "shared sign-in"); + } + assert.deepStrictEqual(actions, []); + }), + ); + it.effect("routes typed interactions to the flow owner and rejects unsupported controllers", () => + Effect.gen(function* () { + const { service, actions } = yield* makeHarness({ responds: true }); + yield* service.start({ instanceId }, owner); + const request = { + instanceId, + flowId, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const rejected = yield* service.respond(request, otherOwner).pipe(Effect.flip); + assert.strictEqual(rejected.operation, "respond"); + yield* service.respond(request, owner); + assert.strictEqual(actions.at(-1), "respond:browser"); + const unsupported = yield* makeHarness(); + const error = yield* unsupported.service.respond(request, owner).pipe(Effect.flip); + assert.include(error.detail, "does not accept"); + }), + ); it.effect("stops routed sessions before sign-in, including for a disabled instance", () => Effect.gen(function* () { const { service, actions, sessions } = yield* makeHarness({ @@ -606,3 +716,200 @@ describe("ProviderAuthService", () => { }), ); }); + +it.effect("queued logout prompts resolve the current controller after provider replacement", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const lookup = yield* Deferred.make(); + let observeLookup = false; + const harness = yield* makeHarness({ + beforeLogout: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + onLookup: Effect.suspend(() => + observeLookup ? Deferred.succeed(lookup, undefined).pipe(Effect.asVoid) : Effect.void, + ), + }); + const first = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + observeLookup = true; + const queued = yield* harness.service + .tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }) + .pipe(Effect.forkChild); + yield* Deferred.await(lookup); + let replacementLoggedOut = false; + harness.replaceAuth({ + ...harness.auth, + logout: (stopSessions) => + stopSessions.pipe( + Effect.andThen( + Effect.sync(() => { + replacementLoggedOut = true; + return idleAuthState; + }), + ), + ), + }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(first); + assert.isTrue(yield* Fiber.join(queued)); + assert.isTrue(replacementLoggedOut); + assert.strictEqual(harness.actions.filter((action) => action === "native-logout").length, 1); + }).pipe(Effect.scoped), +); + +it.effect.each(["start", "logout", "prompt"] as const)( + "%s uses the same credential controller for shared exclusion and mutation during replacement", + (action) => + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const continueCheck = yield* Deferred.make(); + const replacementPeerId = ProviderInstanceId.make("replacement-shared-peer"); + const harness = yield* makeHarness({ + sharedCredentials: true, + sharedBusyEffect: Deferred.succeed(checked, undefined).pipe( + Effect.andThen(Deferred.await(continueCheck)), + Effect.as(false), + ), + sessions: [ + makeSession("old-shared", otherInstanceId), + makeSession("replacement-shared", replacementPeerId), + ], + }); + harness.addInstance( + makeInstance({ + instanceId: replacementPeerId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + isChangingCredentials: Effect.succeed(true), + invalidate: Effect.die( + "The unrelated replacement credential binding must stay intact.", + ), + }, + }), + ); + const operation = + action === "start" + ? harness.service.start({ instanceId }, owner) + : action === "logout" + ? harness.service.logout({ instanceId }) + : harness.service.tryHandlePromptCommand({ + instanceId, + text: "/logout", + hasAttachments: false, + }); + const running = yield* operation.pipe(Effect.forkChild); + yield* Deferred.await(checked); + let replacementMutations = 0; + harness.replaceAuth({ + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-binding" }, + start: () => + Effect.sync(() => { + replacementMutations++; + return waitingAuthState; + }), + logout: () => + Effect.sync(() => { + replacementMutations++; + return idleAuthState; + }), + }); + yield* Deferred.succeed(continueCheck, undefined); + yield* Fiber.join(running); + assert.equal(replacementMutations, 0); + assert.include(harness.actions, action === "start" ? "start-sign-in" : "native-logout"); + assert.isFalse(harness.sessions.has(ThreadId.make("old-shared"))); + assert.isTrue(harness.sessions.has(ThreadId.make("replacement-shared"))); + const blocked = yield* Effect.flip(harness.service.logout({ instanceId })); + assert.include(blocked.detail, "shared sign-in"); + assert.equal(replacementMutations, 0); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { owner: "provider" as const, key: "different-binding" }, + { owner: "t3" as const, key: "shared" }, +])( + "does not invalidate a peer that switches credential binding during session draining %#", + (binding) => + Effect.gen(function* () { + const draining = yield* Deferred.make(); + const continueDrain = yield* Deferred.make(); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [makeSession("shared-draining", otherInstanceId)], + beforeStop: Deferred.succeed(draining, undefined).pipe( + Effect.andThen(Deferred.await(continueDrain)), + ), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(draining); + let replacementInvalidated = false; + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: binding, + invalidate: Effect.sync(() => { + replacementInvalidated = true; + }), + }, + }), + ); + yield* Deferred.succeed(continueDrain, undefined); + yield* Fiber.join(logout); + assert.isFalse(replacementInvalidated); + assert.isFalse(harness.sessions.has(ThreadId.make("shared-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); + +it.effect.each(["selection", "drain"] as const)( + "preserves replacement peer sessions when credentials change during session %s", + (phase) => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const proceed = yield* Deferred.make(); + const block = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ); + const harness = yield* makeHarness({ + sharedCredentials: true, + sessions: [ + makeSession("target-draining"), + makeSession("peer-replacement", otherInstanceId), + ], + bindings: [ + makeBinding("target-draining", "running"), + makeBinding("peer-persisted", "running", otherInstanceId), + ], + ...(phase === "selection" ? { beforeListSessions: block } : { beforeStop: block }), + }); + const logout = yield* harness.service.logout({ instanceId }).pipe(Effect.forkChild); + yield* Deferred.await(entered); + harness.replaceInstance( + makeInstance({ + instanceId: otherInstanceId, + enabled: true, + auth: { + ...harness.auth, + credentialBinding: { owner: "provider", key: "replacement-credentials" }, + invalidate: Effect.die("The replacement peer's credentials must not be invalidated."), + }, + }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(logout); + assert.isTrue(harness.sessions.has(ThreadId.make("peer-replacement"))); + assert.notInclude(harness.actions, "stop:peer-replacement"); + assert.notInclude(harness.actions, "stop:peer-persisted"); + assert.isFalse(harness.sessions.has(ThreadId.make("target-draining"))); + assert.include(harness.actions, "native-logout"); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/provider/Layers/ProviderAuthService.ts b/apps/server/src/provider/Layers/ProviderAuthService.ts index 3b69c5082528..f9213268a33a 100644 --- a/apps/server/src/provider/Layers/ProviderAuthService.ts +++ b/apps/server/src/provider/Layers/ProviderAuthService.ts @@ -2,8 +2,9 @@ import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; -import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; +import * as ProviderAuthService from "../Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -12,6 +13,7 @@ export const makeProviderAuthService = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry; const providers = yield* ProviderService; const directory = yield* ProviderSessionDirectory; + const credentialChanges = yield* Semaphore.make(1); const getController = Effect.fn("ProviderAuthService.getController")(function* ( instanceId: ProviderInstanceId, @@ -30,9 +32,25 @@ 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. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, + binding: ProviderAuthService.ProviderAuthController["credentialBinding"], ) { + const affectedIds = new Set([ + instanceId, + ...(binding === undefined + ? [] + : (yield* registry.listInstances) + .filter( + (instance) => + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ) + .map((instance) => instance.instanceId)), + ]); const bindings = yield* directory.listBindings().pipe( Effect.mapError( () => @@ -44,39 +62,140 @@ export const makeProviderAuthService = Effect.gen(function* () { ), ); const sessions = yield* providers.listSessions(); - const threadIds = new Set( - bindings - .filter( - (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped", - ) - .map((binding) => binding.threadId), + const sessionsToStop = new Map( + bindings.flatMap((session) => + session.providerInstanceId !== undefined && + affectedIds.has(session.providerInstanceId) && + session.status !== "stopped" + ? [[session.threadId, session.providerInstanceId] as const] + : [], + ), ); for (const session of sessions) { - if (session.providerInstanceId === instanceId) { - threadIds.add(session.threadId); + if (session.providerInstanceId !== undefined && affectedIds.has(session.providerInstanceId)) { + sessionsToStop.set(session.threadId, session.providerInstanceId); } } yield* Effect.forEach( - threadIds, - (threadId) => - providers.stopSession({ threadId }).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "stopSessions", - detail: "Could not stop all sessions for this provider. Try again.", - }), - ), - ), + sessionsToStop, + ([threadId, sessionInstanceId]) => + Effect.gen(function* () { + if (sessionInstanceId !== instanceId) { + const current = yield* registry.getInstance(sessionInstanceId); + if ( + !binding || + current?.auth?.credentialBinding?.key !== binding.key || + current.auth.credentialBinding.owner !== binding.owner + ) + return; + } + yield* providers.stopSession({ threadId }).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "stopSessions", + detail: "Could not stop all sessions for this provider. Try again.", + }), + ), + ); + }), { discard: true }, ); + if (binding) { + yield* Effect.forEach( + (yield* registry.listInstances).filter( + (instance) => + instance.instanceId !== instanceId && + affectedIds.has(instance.instanceId) && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner, + ), + (instance) => instance.auth?.invalidate ?? Effect.void, + { discard: true }, + ); + } }); - return ProviderAuthService.of({ + const checkSharedBinding = Effect.fnUntraced(function* ( + instanceId: ProviderInstanceId, + operation: "start" | "logout", + auth: ProviderAuthService.ProviderAuthController, + ) { + const binding = auth.credentialBinding; + if (!binding) return; + const instances = yield* registry.listInstances; + for (const instance of instances) { + if ( + instance.instanceId !== instanceId && + instance.auth?.credentialBinding?.key === binding.key && + instance.auth.credentialBinding.owner === binding.owner && + instance.auth.isChangingCredentials && + (yield* instance.auth.isChangingCredentials) + ) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: + "Another provider instance is changing this shared sign-in. Finish or cancel it first.", + }); + } + } + }); + + return ProviderAuthService.ProviderAuthService.of({ + reconnectProfile: Effect.fnUntraced(function* (input) { + const auth = yield* getController(input.instanceId, "export"); + if (!auth.reconnectProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "export", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.reconnectProfile(input.methodId); + }), + importProfile: (input) => + credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "import"); + yield* checkSharedBinding(input.instanceId, "start", auth); + if (!auth.importProfile) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "import", + detail: "This provider does not support ChatGPT profile transfer.", + }); + return yield* auth.importProfile( + input.profile, + stopSessions(input.instanceId, auth.credentialBinding), + ); + }), + ), start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { - const auth = yield* getController(input.instanceId, "start"); - return yield* auth.start(ownerSessionId, stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "start"); + yield* checkSharedBinding(input.instanceId, "start", auth); + return yield* auth.start( + ownerSessionId, + stopSessions(input.instanceId, auth.credentialBinding), + input.methodId, + input.returnUrl, + input.callbackMode, + ); + }), + ); + }), + respond: Effect.fn("ProviderAuthService.respond")(function* (input, ownerSessionId) { + const auth = yield* getController(input.instanceId, "respond"); + if (!auth.respond) { + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "respond", + detail: "This provider does not accept this sign-in interaction.", + }); + } + return yield* auth.respond(ownerSessionId, input); }), complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) { const auth = yield* getController(input.instanceId, "complete"); @@ -87,8 +206,13 @@ export const makeProviderAuthService = Effect.gen(function* () { return yield* auth.cancel(ownerSessionId, input.flowId); }), logout: Effect.fn("ProviderAuthService.logout")(function* (input) { - const auth = yield* getController(input.instanceId, "logout"); - return yield* auth.logout(stopSessions(input.instanceId)); + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + yield* checkSharedBinding(input.instanceId, "logout", auth); + return yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + }), + ); }), subscribe: (input, ownerSessionId) => Effect.gen(function* () { @@ -110,11 +234,21 @@ export const makeProviderAuthService = Effect.gen(function* () { if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) { return false; } - yield* instance.auth.logout(stopSessions(input.instanceId)); - return true; + return yield* credentialChanges.withPermit( + Effect.gen(function* () { + const auth = yield* getController(input.instanceId, "logout"); + if (!auth.isLogoutPrompt?.(input.text, input.hasAttachments)) return false; + yield* checkSharedBinding(input.instanceId, "logout", auth); + yield* auth.logout(stopSessions(input.instanceId, auth.credentialBinding)); + return true; + }), + ); }, ), }); }); -export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService); +export const ProviderAuthServiceLive = Layer.effect( + ProviderAuthService.ProviderAuthService, + makeProviderAuthService, +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 25dafa5ba040..0186d2dc4503 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -1,3 +1,6 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; /** * Multi-instance validation slices for `ProviderInstanceRegistryLive`. * @@ -25,6 +28,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + EnvironmentId, type ClaudeSettings, type CodexSettings, type CursorSettings, @@ -34,7 +38,7 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; -import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -56,7 +60,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -178,6 +182,7 @@ const makeTildeProviderFixtures = Effect.fn( claudePath, [ "#!/usr/bin/env node", + 'import { existsSync } from "node:fs";', 'import * as NodeReadline from "node:readline";', 'if (process.argv.includes("--version")) {', ' process.stdout.write("claude 2.1.219\\n");', @@ -186,7 +191,26 @@ const makeTildeProviderFixtures = Effect.fn( "const lines = NodeReadline.createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + " const marker = process.env.T3_CLAUDE_RESET_MARKER;", + " if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && existsSync(marker)) {", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "error", request_id: message.request_id, error: "usage failed" },', + ' }) + "\\n");', + " return;", + " }", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "success", request_id: message.request_id, response: {', + ' session: {}, subscription_type: "pro", rate_limits_available: true,', + " rate_limits: { five_hour: { utilization: marker && existsSync(marker) ? 0 : 100, resets_at: null } },", + " } },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -226,12 +250,23 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { prefix: "provider-instance-registry-test", }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -338,6 +373,44 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("reports Codex's answer when a redemption changed nothing", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fileSystem = yield* FileSystem.FileSystem; + const fixtures = yield* makeTildeProviderFixtures(); + yield* fileSystem.writeFileString( + fixtures.codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ + rootThreadId: "probe-thread", + notifications: [], + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + failRateLimitsRead: true, + resetCreditOutcome: "alreadyRedeemed", + }), + ); + const codexId = ProviderInstanceId.make("codex_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + }, + }); + const codex = yield* registry.getInstance(codexId); + expect(codex).toBeDefined(); + // The usage read fails, so the re-probe cannot confirm new limits. + yield* codex!.snapshot.refresh; + expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); + }).pipe(Effect.provide(testLayer)), + ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { if (yield* isHostWindows) return; @@ -392,6 +465,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const marker = path.join(fixtures.claudeHomePath, "redeemed"); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"fake-token"}}', + ); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".claude.json"), + '{"oauthAccount":{"organizationUuid":"fake-org"}}', + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/api/oauth/usage")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], + }, + }), + ); + } + if (request.url.endsWith("/reset_rate_limits")) { + yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); + return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); + } + return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); + }), + ); + const instanceId = ProviderInstanceId.make("claude_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [ + { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, + ...(claim.usageFailsAfterClaim + ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] + : []), + ], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const instance = yield* registry.getInstance(instanceId); + expect(instance).toBeDefined(); + const before = yield* instance!.snapshot.refresh; + expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); + expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); + const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); + return { outcome, after: yield* instance!.snapshot.getSnapshot }; + }).pipe( + // macOS logins live in the Keychain, where resets are never read. + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(testLayer), + ); + + it.live("refreshes Claude usage after redeeming a reset", () => + Effect.gen(function* () { + const { outcome, after } = yield* redeemClaudeReset({ + result: "reset", + usageFailsAfterClaim: false, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); + expect(after.usageLimits?.windows[0]?.usedPercent).toBe(0); + }), + ); + + it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => + Effect.gen(function* () { + const { outcome } = yield* redeemClaudeReset({ + result: "already_used", + usageFailsAfterClaim: true, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); + }), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => @@ -446,7 +609,20 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = OpenCodeRuntimeLive.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge( + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + ), + Layer.provideMerge(Layer.mock(ServerSecretStore)({})), + Layer.provideMerge( + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed( + EnvironmentId.make("00000000-0000-4000-8000-000000000001"), + ), + }), + ), + ); const testLayer = AntigravityInstallation.layer.pipe( Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { @@ -459,7 +635,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 1dcd20cf69c5..36e1dcf26fd5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1,3 +1,6 @@ +import { CodexInstallation } from "../CodexInstallation.ts"; +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; @@ -16,6 +19,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { + EnvironmentId, ClaudeSettings, CodexSettings, DEFAULT_SERVER_SETTINGS, @@ -38,7 +42,8 @@ import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -76,13 +81,20 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const withBundledCompatibility = (snapshot: ServerProvider) => + applyProviderCompatibility( + snapshot, + undefined, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); +// Provider metadata checks use a bundled manifest and stubbed HTTP. const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), -); +).pipe(Layer.provideMerge(ModelManifest.layerTest)); const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, @@ -361,7 +373,16 @@ const awaitPersistedProvider = ( Effect.forkScoped, ); -it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))( +const TestNodeServices = Layer.mergeAll( + NodeServices.layer, + Layer.mock(CodexInstallation)({ managedDirectory: "unused-managed-installation" }), + Layer.mock(ServerSecretStore)({}), + Layer.succeed(ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), + }), +); + +it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), TestHttpClientLive))( "ProviderRegistry", (it) => { describe("checkCodexProviderStatus", () => { @@ -1753,7 +1774,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withBundledCompatibility(codexProvider), ); yield* Ref.set(catalogSnapshot, changedCatalogProvider); @@ -1765,7 +1786,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( changedProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withBundledCompatibility(codexProvider), ); }).pipe(Effect.provide(runtimeServices)); @@ -1879,10 +1900,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Fiber.join(persisted); const cachedProvider = yield* readProviderStatusCache(filePath); - assert.deepStrictEqual(cachedProvider, { - ...refreshedProvider, - models: [...initialProvider.models], - }); + assert.deepStrictEqual( + cachedProvider, + withBundledCompatibility({ + ...refreshedProvider, + models: [...initialProvider.models], + }), + ); }).pipe(Effect.provide(runtimeServices)); }), ); @@ -2094,10 +2118,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ + withBundledCompatibility(cachedProvider), + ]); assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - cachedProvider, + withBundledCompatibility(cachedProvider), ]); }).pipe(Effect.provide(runtimeServices)); }), @@ -2205,7 +2233,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withBundledCompatibility(codexProvider), + ]); yield* Ref.set(failNextList, true); yield* PubSub.publish(changes, undefined); @@ -2304,7 +2334,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -2403,7 +2433,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -2519,7 +2549,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -2581,8 +2611,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -2736,6 +2765,42 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("reads banked resets only for subscription logins", () => + Effect.gen(function* () { + const check = (overrides: Partial) => + checkClaudeProviderStatus( + defaultClaudeSettings, + () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + apiProvider: undefined, + slashCommands: [], + usage: { rate_limits_available: true, rate_limits: {} }, + ...overrides, + }), + undefined, + undefined, + undefined, + undefined, + () => Effect.succeed({ availableCount: 2 }), + ); + const subscription = yield* check({ subscriptionType: "max" }); + const bedrock = yield* check({ apiProvider: "bedrock" }); + assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); + assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("does not duplicate Claude in full subscription labels", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..a65bc66edf32 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; + const manifestService = yield* ModelManifest.ModelManifest; + const serviceScope = yield* Effect.scope; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( ), ), ); - const providersRef = yield* Ref.make>(cachedProviders); + const initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const manifest = yield* manifestService.current; const nextProvidersWithUpdateState = yield* Effect.forEach( nextProviders, applyProviderUpdateState, @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( ); } - const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providers = orderProviderSnapshots( + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), ); @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); + const compatibilityRefreshRunning = yield* Ref.make(false); const syncProvider = Effect.fn("syncProvider")(function* ( provider: ServerProvider, options?: { readonly publish?: boolean; }, ) { - return yield* upsertProviders([provider], options); + 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. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; }); const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 5afb3da81d12..91c077048ccb 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -418,6 +418,7 @@ function makeProviderServiceLayer( readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; readonly supportsConversationRollback?: boolean; readonly analyticsLayer?: Layer.Layer; + readonly settingsLayer?: typeof defaultServerSettingsLayer; readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"]; } = {}, ) { @@ -450,7 +451,7 @@ function makeProviderServiceLayer( Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(defaultServerSettingsLayer), + Layer.provide(input.settingsLayer ?? defaultServerSettingsLayer), Layer.provide(serverConfigTestLayer), Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest), Layer.provide( @@ -648,6 +649,78 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("ProviderServiceLive shutdown leaves settled session rows untouched", () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const persistence = yield* Layer.build( + ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory))), + ), + ); + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide(persistence), + ); + const seed = (threadId: ThreadId, status: "running" | "stopped", activeTurnId: TurnId | null) => + directory.upsert({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + status, + runtimePayload: { cwd: "/repo", activeTurnId }, + }); + const readBindings = directory + .listBindings() + .pipe( + Effect.map((bindings) => new Map(bindings.map((binding) => [binding.threadId, binding]))), + ); + const settledId = asThreadId("shutdown-settled"); + const runningId = asThreadId("shutdown-running"); + const stoppedWithTurnId = asThreadId("shutdown-stopped-with-turn"); + yield* seed(settledId, "stopped", null); + yield* seed(runningId, "running", asTurnId("running-turn")); + yield* seed(stoppedWithTurnId, "stopped", asTurnId("stale-turn")); + const settledBefore = (yield* readBindings).get(settledId); + assert(settledBefore !== undefined); + + const scope = yield* Scope.make(); + yield* Layer.build( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory)), + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]), + ), + ), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ).pipe(Scope.provide(scope)); + yield* TestClock.adjust("1 minute"); + yield* Scope.close(scope, Exit.void); + + const byThread = yield* readBindings; + assert.deepStrictEqual(byThread.get(settledId), settledBefore); + for (const threadId of [runningId, stoppedWithTurnId]) { + const binding = byThread.get(threadId); + assert.equal(binding?.status, "stopped"); + assert.propertyVal(binding?.runtimePayload, "activeTurnId", null); + assert.propertyVal(binding?.runtimePayload, "lastRuntimeEvent", "provider.stopAll"); + } + const [stoppedAll] = recordedAnalytics.eventsByName("provider.sessions.stopped_all"); + assert.equal(stoppedAll?.properties?.stoppedSessionCount, 2); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => Effect.gen(function* () { const recordedAnalytics = makeRecordingAnalytics(); @@ -5139,6 +5212,57 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); + it.effect("rejects a file when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-file-path-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [ + { + type: "file", + id: "thread-attach-12345678-1234-1234-1234-123456789abc-zip", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 1024, + }, + ], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + + it.effect("sends a native image when its path cannot fit in the prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-image-path-context-limit"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + validation.codex.sendTurn.mockClear(); + const attachment = { + type: "image" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-png", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1024, + }; + const input = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + yield* provider.sendTurn({ threadId, input, attachments: [attachment] }); + assert.equal(validation.codex.sendTurn.mock.calls[0]?.[0].input, input); + assert.deepEqual(validation.codex.sendTurn.mock.calls[0]?.[0].attachments, [attachment]); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -5304,13 +5428,11 @@ const listThreadIds = vi.fn(() => Effect.succeed([activeSessionThreadId, historicalSessionThreadId]), ); const getBinding = vi.fn((threadId: ThreadId) => - Effect.succeed( - Option.some({ - threadId, - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, - }), - ), + Effect.succeedSome({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + }), ); const boundedListing = makeProviderServiceLayer({ directory: { @@ -5381,6 +5503,7 @@ describe("agent browser access", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -5560,3 +5683,89 @@ describe("agent browser access", () => { }).pipe(Effect.provide(NodeServices.layer)), ); }); + +const chatGptAnalytics = makeRecordingAnalytics(); +const chatGptAdapter = makeFakeCodexAdapter(); +const chatGptTelemetry = makeProviderServiceLayer({ + analyticsLayer: chatGptAnalytics.layer, + settingsLayer: ServerSettings.ServerSettingsService.layerTest({ + providerInstances: { + [secondaryCodexInstanceId]: { driver: CODEX_DRIVER, config: { setupMode: "managed" } }, + }, + }), + registry: makeStaticInstanceRegistry([[secondaryCodexInstanceId, chatGptAdapter.adapter]]), +}); +chatGptTelemetry.layer("ChatGPT connector turn analytics", (it) => { + it.effect("tags attempts, sends, and one terminal outcome without recording the prompt", () => + Effect.gen(function* () { + chatGptAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("chatgpt-analytics-success"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const turn = yield* provider.sendTurn({ threadId, input: "private test prompt" }); + const drain = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const completion: LegacyProviderRuntimeEvent = { + type: "turn.completed", + eventId: asEventId("chatgpt-completed"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: turn.turnId, + payload: { state: "completed" }, + }; + chatGptAdapter.emit(completion); + chatGptAdapter.emit({ ...completion, eventId: asEventId("chatgpt-completed-duplicate") }); + yield* Fiber.join(drain); + for (const event of [ + "provider.turn.attempted", + "provider.turn.sent", + "provider.turn.completed", + ]) { + const events = chatGptAnalytics.eventsByName(event); + assert.equal(events.length, 1); + assert.equal(events[0]?.properties?.subscriptionSharing, true); + assert.notProperty(events[0]?.properties ?? {}, "input"); + assert.notProperty(events[0]?.properties ?? {}, "threadId"); + assert.notProperty(events[0]?.properties ?? {}, "providerInstanceId"); + } + }), + ); + it.effect("records rejected sends as failures without an accepted-turn event", () => + Effect.gen(function* () { + chatGptAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("chatgpt-analytics-rejection"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + chatGptAdapter.sendTurn.mockImplementationOnce(() => + Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: CODEX_DRIVER, threadId })), + ); + const result = yield* provider + .sendTurn({ threadId, input: "private rejected prompt" }) + .pipe(Effect.result); + assert.equal(result._tag, "Failure"); + assert.equal(chatGptAnalytics.eventsByName("provider.turn.attempted").length, 1); + assert.equal(chatGptAnalytics.eventsByName("provider.turn.sent").length, 0); + const rejected = chatGptAnalytics.eventsByName("provider.turn.rejected"); + assert.equal(rejected.length, 1); + assert.deepStrictEqual(rejected[0]?.properties, { + provider: CODEX_DRIVER, + subscriptionSharing: true, + errorType: "ProviderAdapterSessionNotFoundError", + }); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d79ce83b8890..f469b7b81428 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -264,6 +264,7 @@ interface TurnAnalyticsMetadata { readonly provider: ProviderDriverKind; readonly startedAtMs: number; readonly mixedModels: boolean; + readonly subscriptionSharing?: boolean; readonly model?: string; readonly effort?: string; readonly interactionMode?: string; @@ -428,6 +429,14 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +/** Stopped rows with no active turn are settled; shutdown leaves them untouched. */ +function isSettledBinding(binding: ProviderSessionDirectory.ProviderRuntimeBinding): boolean { + if (binding.status !== "stopped") return false; + const payload = binding.runtimePayload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; + return !("activeTurnId" in payload) || payload.activeTurnId == null; +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -536,6 +545,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { ...input.completion.terminalProperties, + ...(metadata?.subscriptionSharing ? { subscriptionSharing: true } : {}), ...(metadata?.model ? { model: metadata.model } : {}), ...(metadata?.effort ? { effort: metadata.effort } : {}), ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}), @@ -581,6 +591,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( readonly runtimeMode: string | undefined; }) { const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now); + const settings = yield* serverSettings.getSettings.pipe(Effect.option); + const instance = Option.isSome(settings) + ? settings.value.providerInstances[input.providerInstanceId] + : undefined; + const subscriptionSharing = + input.provider === "codex" && + (instance + ? instance.driver === "codex" && + typeof instance.config === "object" && + instance.config !== null && + "setupMode" in instance.config && + instance.config.setupMode === "managed" + : input.providerInstanceId === "codex" && + Option.isSome(settings) && + settings.value.providers.codex.setupMode === "managed"); turnAnalyticsRequestId += 1; const requestId = turnAnalyticsRequestId; const effort = turnEffort(input.modelSelection); @@ -593,6 +618,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }; const metadata: TurnAnalyticsMetadata = { provider: input.provider, + ...(subscriptionSharing ? { subscriptionSharing: true } : {}), startedAtMs, mixedModels: false, requestId, @@ -1764,10 +1790,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); - if (isPastedText && !appended) { + // Most adapters see generic files only through this path line, so a file + // without one would be silently dropped. Images still go natively. + if (!appended && attachment.type === "file") { return yield* toValidationError( "ProviderService.sendTurn", - `Input plus pasted-text attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, + `Input plus attachment context exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character limit`, ); } } @@ -1859,6 +1887,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* McpSessionRegistry.touchActiveMcpThread(input.threadId); const analyticsModelSelection = input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined; + let subscriptionSharing = false; const turn = yield* Effect.acquireUseRelease( beginTurnAnalytics({ providerInstanceId: routed.instanceId, @@ -1870,7 +1899,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }), (turnMetadata) => Effect.gen(function* () { - const turn = yield* routed.adapter.sendTurn(input); + subscriptionSharing = turnMetadata.subscriptionSharing === true; + yield* analytics.record("provider.turn.attempted", { + provider: routed.adapter.provider, + ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}), + model: input.modelSelection?.model, + runtimeMode: routed.runtimeMode, + }); + const turn = yield* routed.adapter.sendTurn(input).pipe( + Effect.tapError((error) => + analytics.record("provider.turn.rejected", { + provider: routed.adapter.provider, + ...(turnMetadata.subscriptionSharing ? { subscriptionSharing: true } : {}), + errorType: error._tag, + }), + ), + ); yield* associateTurnAnalytics({ providerInstanceId: routed.instanceId, threadId: input.threadId, @@ -1904,6 +1948,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* analytics.record("provider.turn.sent", { provider: routed.adapter.provider, + ...(subscriptionSharing ? { subscriptionSharing: true } : {}), model: input.modelSelection?.model, interactionMode: input.interactionMode, // Session-start events alone skew runtime mode toward users who toggle @@ -2436,7 +2481,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Continuation is project-scopable, so decide it per session's project; // without orchestration the environment value is all there is. const stopSettings = yield* serverSettings.getSettings.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.orElseSucceed(() => Option.none()), ); const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( @@ -2469,7 +2514,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return [completed, state] as const; }); yield* recordCompletedTurnProperties(properties); - const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => adapter.listSessions().pipe( @@ -2500,7 +2544,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); McpProviderSession.clearAllMcpProviderSessions(); - const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); + // Stopped rows stay for their resume cursors, so long-lived installs hold + // thousands. Only rewrite the ones this shutdown actually stops. + const bindings = yield* directory.listBindings().pipe( + Effect.map((all) => all.filter((binding) => !isSettledBinding(binding))), + Effect.orElseSucceed(() => []), + ); yield* Effect.forEach(bindings, (binding) => Effect.gen(function* () { const providerInstanceId = dieOnMissingBindingInstanceId( @@ -2520,8 +2569,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); }), ).pipe(Effect.asVoid); + // Not `sessionCount`: that older property counted every row, so a new name + // keeps the two meanings in separate series. yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, + stoppedSessionCount: bindings.length, }); yield* analytics.flush; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 8b41bd3e518c..45615fe7b440 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -372,6 +372,41 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }), ); + it.effect("lists only bindings that are not stopped when asked", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const statuses = ["running", "starting", "error", "stopped"] as const; + const threadIds = new Set(); + + for (const status of statuses) { + const threadId = ThreadId.make(`thread-exclude-stopped-${status}`); + threadIds.add(threadId); + yield* runtimeRepository.upsert({ + threadId, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status, + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); + } + + const liveStatuses = (yield* directory.listBindings({ excludeStopped: true })) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + const allStatuses = (yield* directory.listBindings()) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + + assert.deepEqual(liveStatuses.toSorted(), ["error", "running", "starting"]); + assert.deepEqual(allStatuses.toSorted(), ["error", "running", "starting", "stopped"]); + }), + ); + it.effect( "resets adapterKey to the new provider when provider changes without an explicit adapter key", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 29ec8d2ed168..0e9de8fb868f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -93,9 +93,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((binding) => Option.some(binding)), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe(Effect.asSome), }), ), ); @@ -184,8 +182,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); - const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => - repository.list().pipe( + const listBindings: ProviderSessionDirectoryShape["listBindings"] = (options) => + repository.list(options).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), Effect.flatMap((rows) => Effect.forEach( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 8f2d57429dd5..3d71e4d7ec94 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -252,6 +252,7 @@ describe("ProviderSessionReaper", () => { getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 9991ca6a9d95..c3c0d627f3bb 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -55,15 +55,13 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = ); const sweep = Effect.gen(function* () { - const bindings = yield* directory.listBindings(); + // Stopped rows stay for their resume cursors and far outnumber live + // ones, so the query skips them. + const bindings = yield* directory.listBindings({ excludeStopped: true }); const now = yield* Clock.currentTimeMillis; let reapedCount = 0; for (const binding of bindings) { - if (binding.status === "stopped") { - continue; - } - const lastSeenMs = Date.parse(binding.lastSeenAt); if (Number.isNaN(lastSeenMs)) { yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { @@ -147,7 +145,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = if (reapedCount > 0) { yield* Effect.logInfo("provider.session.reaper.sweep-complete", { reapedCount, - totalBindings: bindings.length, + liveBindings: bindings.length, }); } }); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.test.ts b/apps/server/src/provider/Layers/claudeResetCredits.test.ts new file mode 100644 index 000000000000..17f9a03c48bd --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ClaudeResetCredits from "./claudeResetCredits.ts"; + +const NOW = Date.parse("2026-09-22T12:00:00.000Z"); +const grant = (overrides: Record) => ({ + id: "grant_a", + resets_left: 1, + usable_now: true, + ...overrides, +}); + +const writeLogin = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + path.join(directory, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"oauth-token"}}', + ); + const accountConfigPath = path.join(directory, ".claude.json"); + yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); + return { configDir: directory, accountConfigPath }; +}); + +const respond = (status: number, body: unknown) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), + ); +const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); + +describe("claudeResetCreditsToContract", () => { + it("counts live grants and pins the next usable one", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "grant_a", + grants: [ + grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), + grant({ id: "paused", paused: true }), + grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), + grant({ id: "garbled", ends_at: "not a date" }), + grant({ id: "date_only", ends_at: "2026-10-01" }), + grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), + grant({ id: "empty", ends_at: "" }), + grant({ id: "Not Valid" }), + grant({ id: "grant_b", resets_left: 3, usable_now: false }), + ], + }, + NOW, + ), + ).toEqual({ + availableCount: 2, + nextCreditId: "grant_a", + nextExpiresAt: "2026-10-01T00:00:00.000Z", + }); + }); + + it("offers nothing to redeem without a usable next grant or an eligible account", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, + NOW, + ), + ).toEqual({ availableCount: 0 }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), + ).toEqual({ + availableCount: 0, + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: false, grants: [grant({})] }, + NOW, + ), + ).toBeUndefined(); + expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); + }); +}); + +effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { + it.effect("reads the grants with the CLI's request", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const client = HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + }), + ); + + it.effect("reads nothing from keychain logins or failed requests", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HttpClient.HttpClient, refuseRequests), + ); + const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, respond(429, {})), + ); + expect([darwin, limited]).toEqual([undefined, undefined]); + }), + ); +}); + +const ClaimBody = Schema.fromJsonString( + Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), +); +const decodeClaimBody = Schema.decodeEffect(ClaimBody); + +const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => + Effect.gen(function* () { + const login = yield* writeLogin; + return yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + ...ids, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + ); + }); + +effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { + it.effect("claims the grant for the organization", () => + Effect.gen(function* () { + const client = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.method).toBe("POST"); + expect(request.url).toBe( + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + expect(yield* decodeClaimBody(body)).toEqual({ + program: "cedar_ember", + grant_id: "grant_a", + request_id: "r-1", + }); + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + }).pipe(Effect.orDie), + ); + expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); + }), + ); + + it.effect("maps each answer to an outcome or a failure", () => + Effect.gen(function* () { + for (const [result, outcome] of [ + ["not_limited", "nothingToReset"], + ["already_used", "alreadyRedeemed"], + ["ineligible", "noCredit"], + ] as const) { + expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); + } + for (const client of [ + respond(200, { result: "cooldown" }), + respond(429, {}), + respond(401, {}), + ]) { + const result = yield* consume(client); + expect(result).toMatchObject({ _tag: "Failure" }); + // Claude answered, so a retry must be a new claim. + if (result._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); + } + } + // No answer, or Claude could not confirm the claim: a retry is the same claim. + for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { + const unanswered = yield* consume(client); + expect(unanswered).toMatchObject({ _tag: "Failure" }); + if (unanswered._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( + false, + ); + } + } + }), + ); + + it.effect("times out a stalled claim body", () => + Effect.gen(function* () { + const login = yield* writeLogin; + const readingBody = yield* Deferred.make(); + const client = HttpClient.make((request) => { + const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + Object.defineProperty(response, "json", { + value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), + }); + return Effect.succeed(response); + }); + const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + grantId: "grant_a", + requestId: "r-1", + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(readingBody); + yield* TestClock.adjust("26 seconds"); + expect(yield* Fiber.join(claim)).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "ClaudeResetCreditError", + reason: "requestFailed", + cause: { _tag: "TimeoutError" }, + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("refuses malformed ids without sending anything", () => + Effect.gen(function* () { + for (const ids of [ + { grantId: "Bad Grant", requestId: "r-1" }, + { grantId: "grant_a", requestId: "has space" }, + ]) { + expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts new file mode 100644 index 000000000000..f02c17214c83 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.ts @@ -0,0 +1,270 @@ +/** + * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the + * grants from the OAuth usage endpoint and claims one against the + * organization; this module does the same with the credentials the CLI keeps + * in its config directory. macOS keeps them in the keychain, so there the + * feature is not offered. + * + * @module provider/Layers/claudeResetCredits + */ +import * as NodeOS from "node:os"; +import type { + ProviderConsumeResetCreditOutcome, + ServerProviderResetCredits, +} from "@t3tools/contracts"; +import { HostProcessPlatform } 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 Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +const API_BASE = "https://api.anthropic.com"; +const PROGRAM = "cedar_ember"; +const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; +const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +const Credentials = Schema.Struct({ + claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), +}); +const Config = Schema.Struct({ + oauthAccount: Schema.optional( + Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), + ), +}); +const Grant = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(GRANT_ID)), + resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + ends_at: Schema.optional(Schema.NullOr(Schema.String)), + paused: Schema.optional(Schema.Boolean), + usable_now: Schema.optional(Schema.Boolean), +}); +const decodeGrant = Schema.decodeUnknownOption(Grant); +const CedarEmber = Schema.Struct({ + eligible: Schema.Boolean, + grants: Schema.optional(Schema.Array(Schema.Unknown)), + next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), +}); +const UsageResponse = Schema.Struct({ + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const ClaimResponse = Schema.Struct({ + result: Schema.Literals([ + "reset", + "already_used", + "not_limited", + "cooldown", + "ineligible", + "unavailable", + ]), +}); + +const RESET_CREDIT_FAILURES = { + malformedCredit: "Claude returned a malformed reset credit.", + loginUnreadable: "Claude could not read its login.", + accountUnreadable: "Claude could not read its account.", + signedOut: "Sign in to Claude again to redeem resets.", + rateLimited: "Claude is rate limiting resets. Try again soon.", + coolingDown: "Claude resets are cooling down. Try again later.", + unconfirmed: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + requestFailed: "Claude could not redeem the reset.", +} as const; + +class ClaudeResetCreditError extends Schema.TaggedError()( + "ClaudeResetCreditError", + { + reason: Schema.Literals( + Object.keys(RESET_CREDIT_FAILURES) as Array, + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return RESET_CREDIT_FAILURES[this.reason]; + } +} + +const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); + +/** + * Every reset failure except `requestFailed` and `unconfirmed` is final: + * Claude answered, or nothing was sent. An unanswered or unconfirmed claim + * retries with the same request id. + */ +export const isSettledClaudeResetCreditFailure = (error: unknown) => + isClaudeResetCreditError(error) && + error.reason !== "requestFailed" && + error.reason !== "unconfirmed"; + +/** Rejects unparseable and calendar-invalid timestamps such as February 30. */ +const isFutureTimestamp = (value: string, nowMs: number) => { + if (!COMPLETE_TIMESTAMP.test(value)) return false; + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return ( + Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) + ); +}; + +/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ +export function claudeResetCreditsToContract( + block: unknown, + nowMs: number, +): ServerProviderResetCredits | undefined { + const parsed = decodeCedarEmber(block); + if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; + const live = (parsed.value.grants ?? []) + .flatMap((raw) => Option.toArray(decodeGrant(raw))) + .filter( + (grant) => + !grant.paused && + grant.usable_now && + (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), + ); + const next = live.find((grant) => grant.id === parsed.value.next_grant_id); + const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + return { + availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, + ...(Option.isSome(nextExpiresAt) + ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } + : {}), + ...(next ? { nextCreditId: next.id } : {}), + }; +} + +const readJson = (schema: S, file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); + }); + +const readAccessToken = (configDir: string) => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; + }); + +const withClaudeHeaders = (token: string, version: string) => + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + "user-agent": `claude-cli/${version} (external, cli)`, + }); + +/** + * Reads the banked resets for the login in `configDir`. Any failure reads as + * "no resets" so the usage bars never break on this optional extra. + */ +export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( + function* (configDir: string, version: string) { + const token = yield* readAccessToken(configDir); + if (!token) return undefined; + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { + urlParams: { cedar_ember: "1", skip_spend: "1" }, + }).pipe(withClaudeHeaders(token, version)), + ); + const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return claudeResetCreditsToContract( + body.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }, + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => undefined), +); + +/** The CLI keeps the account record beside its settings, or in the home directory by default. */ +export const claudeAccountConfigPath = (configDir: string | undefined) => + Effect.map(Path.Path, (path) => + configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), + ); + +const CLAIM_OUTCOMES = { + reset: "reset", + not_limited: "nothingToReset", + already_used: "alreadyRedeemed", + ineligible: "noCredit", +} as const satisfies Record; + +/** + * Claims `grantId`. `requestId` is the idempotency key: a retry with the same + * id is the same claim. Ids are checked before anything is sent. + */ +export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { + readonly configDir: string; + readonly accountConfigPath: string; + readonly version: string; + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + const token = yield* readAccessToken(input.configDir).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), + ); + const config = yield* readJson(Config, input.accountConfigPath).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), + ); + const organization = config.oauthAccount?.organizationUuid?.trim(); + if (!token || !organization) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post( + new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ), + ).pipe( + withClaudeHeaders(token, input.version), + HttpClientRequest.bodyJsonUnsafe({ + program: PROGRAM, + grant_id: input.grantId, + request_id: input.requestId, + }), + ), + ) + .pipe( + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (response.status === 429) { + return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + } + if (response.status === 401 || response.status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + 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. + if (body.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[body.result]; +}); diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 685378b9d79e..e0611e0c50ab 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -13,8 +14,10 @@ import { makeUnavailableUsageLimits, makeUsageLimits, } from "../providerUsageLimits.ts"; +import { readMacCursorAccessToken } from "../cursorCredentialStore.ts"; const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); const CursorUsageResponse = Schema.Struct({ billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), @@ -39,15 +42,11 @@ export function cursorUsageResponseToLimits( : undefined; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { - for (const [key, label] of [ - ["totalPercentUsed", "Monthly"], - ["autoPercentUsed", "Monthly · Auto"], - ["apiPercentUsed", "Monthly · API"], - ] as const) { - const usedPercent = response.planUsage[key]; + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; windows.push({ - id: key, + id, kind: "monthly", label, usedPercent: clampPercent(usedPercent), @@ -63,30 +62,49 @@ export function cursorUsageResponseToLimits( export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( settings: Pick, environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + DEFAULT_CURSOR_API_ENDPOINT + ).replace(/\/$/, ""); let token = environment.CURSOR_AUTH_TOKEN?.trim(); // An explicit API key can name a different account from the stored login. if (!token && environment.CURSOR_API_KEY?.trim()) { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); } const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if ( - !token && - (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file")) - ) { - // Cursor's default macOS login lives in the keychain; a leftover file may be another account. + if (!token && credentialStore === "memory") { return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported", - message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", }); } - if (!token) { + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", + }); + } + token = (yield* Effect.tryPromise(keychainToken))?.trim(); + } else if (!token) { const home = (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); const directory = @@ -106,11 +124,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function } if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); const client = yield* HttpClient.HttpClient; - const endpoint = ( - settings.apiEndpoint.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - "https://api2.cursor.sh" - ).replace(/\/$/, ""); const response = yield* client.execute( HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( HttpClientRequest.bearerToken(token), @@ -127,14 +140,12 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function return cursorUsageResponseToLimits(body, checkedAt); }).pipe( Effect.timeout("10 seconds"), - Effect.catch(() => - Effect.succeed( - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }), - ), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }), ), ); }); diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts index 8c3db6bea80b..7dc561d07ec0 100644 --- a/apps/server/src/provider/Layers/grokUsageLimits.ts +++ b/apps/server/src/provider/Layers/grokUsageLimits.ts @@ -18,6 +18,7 @@ const GrokCredentials = Schema.Record( Schema.Struct({ key: Schema.optional(Schema.String), auth_mode: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), }), ); const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); @@ -41,7 +42,15 @@ export function grokUsageResponseToLimits( ) { const usedPercent = response.config?.creditUsagePercent; if (usedPercent === undefined || !Number.isFinite(usedPercent)) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + // A billing read that succeeded but carries no percentage is an account + // with nothing metered yet, not one that can never report: xAI omits the + // field entirely (rather than sending 0) until usage registers, then fills + // it in. Calling that `unsupported` would strand the account — the Limits + // view drops unsupported entries and deliberately mutes their notice, so a + // freshly signed-in Grok account would vanish with no explanation until it + // happened to be used, and `applyUsageLimitsUpdate` would refuse the + // mid-turn windows that could have recovered it. + return makeUsageLimits({ checkedAt, windows: [] }); } const period = response.config?.currentPeriod; const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); @@ -57,72 +66,97 @@ export function grokUsageResponseToLimits( return makeUsageLimits({ checkedAt, windows: [window] }); } -export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( +/** + * The grok.com login the CLI uses by default, or undefined when the CLI is + * configured to pick another account, endpoint, or an API key. + */ +const readGrokCredential = Effect.fn("readGrokCredential")(function* ( + environment: NodeJS.ProcessEnv, +) { + // T3's ACP adapter explicitly selects API-key auth when this variable is set. + if (environment.XAI_API_KEY?.trim()) return undefined; + // Alternate auth deployments can select another scope or account from the same file. + if ( + [ + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_OAUTH2_ISSUER", + "GROK_OAUTH2_CLIENT_ID", + "GROK_OAUTH2_PRINCIPAL_TYPE", + "GROK_OAUTH2_PRINCIPAL_ID", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_LOCAL_AUTH", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "GROK_MODELS_BASE_URL", + "GROK_CONFIG", + "GROK_CONFIG_PATH", + ].some((name) => environment[name]?.trim()) + ) { + return undefined; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); + for (const configPath of [ + path.join(home, "config.toml"), + path.join(home, "managed_config.toml"), + path.join(home, "requirements.toml"), + "/etc/grok/managed_config.toml", + "/etc/grok/requirements.toml", + ]) { + const config = yield* fs.readFileString(configPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), + }), + ); + // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. + if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { + return undefined; + } + } + const contents = + environment.GROK_AUTH?.trim() || + (yield* fs.readFileString(path.join(home, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const credentials = yield* decodeCredentials(contents); + // Never pick an arbitrary account from other deployments stored in the same file. + const credential = + credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? + credentials["https://accounts.x.ai/sign-in"]; + return credential?.auth_mode === "api_key" ? undefined : credential; +}); + +/** + * Reads the default grok.com login once and reports its usage limits along with + * its email, so the email always names the account whose quota was read. + */ +export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( environment: NodeJS.ProcessEnv = process.env, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - return yield* Effect.gen(function* () { - // T3's ACP adapter explicitly selects API-key auth when this variable is set. - if (environment.XAI_API_KEY?.trim()) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - // Alternate auth deployments can select another scope or account from the same file. - if ( - [ - "GROK_OIDC_ISSUER", - "GROK_OIDC_CLIENT_ID", - "GROK_OAUTH2_ISSUER", - "GROK_OAUTH2_CLIENT_ID", - "GROK_OAUTH2_PRINCIPAL_TYPE", - "GROK_OAUTH2_PRINCIPAL_ID", - "GROK_AUTH_PROVIDER_COMMAND", - "GROK_LOCAL_AUTH", - "GROK_CLI_CHAT_PROXY_BASE_URL", - "GROK_MODELS_BASE_URL", - "GROK_CONFIG", - "GROK_CONFIG_PATH", - ].some((name) => environment[name]?.trim()) - ) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = - environment.GROK_HOME?.trim() || - path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); - for (const configPath of [ - path.join(home, "config.toml"), - path.join(home, "managed_config.toml"), - path.join(home, "requirements.toml"), - "/etc/grok/managed_config.toml", - "/etc/grok/requirements.toml", - ]) { - const config = yield* fs.readFileString(configPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), - }), - ); - // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. - if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - } - const contents = - environment.GROK_AUTH?.trim() || - (yield* fs.readFileString(path.join(home, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const credentials = yield* decodeCredentials(contents); - // Never pick an arbitrary account from other deployments stored in the same file. - const credential = - credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? - credentials["https://accounts.x.ai/sign-in"]; - const token = credential?.auth_mode === "api_key" ? undefined : credential?.key?.trim(); - if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + const probeFailed = makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + const credential = yield* Effect.option( + readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), + ); + if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; + const email = credential.value?.email?.trim() || undefined; + const token = credential.value?.key?.trim(); + if (!token) { + return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; + } + // A failed quota request still knows which account it asked about. + const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( @@ -135,14 +169,7 @@ export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( return grokUsageResponseToLimits(body, checkedAt); }).pipe( Effect.timeout("10 seconds"), - Effect.catch(() => - Effect.succeed( - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Grok could not read usage limits.", - }), - ), - ), + Effect.orElseSucceed(() => probeFailed), ); + return { email, usageLimits }; }); diff --git a/apps/server/src/provider/Layers/openCodeUsageLimits.ts b/apps/server/src/provider/Layers/openCodeUsageLimits.ts index 22b542142cc4..74d0bd75b9f8 100644 --- a/apps/server/src/provider/Layers/openCodeUsageLimits.ts +++ b/apps/server/src/provider/Layers/openCodeUsageLimits.ts @@ -1,4 +1,5 @@ import * as NodeOS from "node:os"; +import * as NodeCrypto from "node:crypto"; import type { ServerProviderUsageWindow } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -95,7 +96,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), }, ]; - return makeUsageLimits({ checkedAt, windows }); + return { + ...makeUsageLimits({ checkedAt, windows }), + // Go's usage response has no account ID. An unkeyed hash matches across + // environments without a shared secret. It permits offline guesses, but + // Go keys are randomly generated. + credentialFingerprint: NodeCrypto.createHash("sha256") + .update("opencode-go\0") + .update(apiKey) + .digest("hex"), + }; }).pipe( Effect.timeout("5 seconds"), Effect.orElseSucceed(() => diff --git a/apps/server/src/provider/Layers/codexResetCredit.test.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts similarity index 69% rename from apps/server/src/provider/Layers/codexResetCredit.test.ts rename to apps/server/src/provider/Layers/resetCreditCoordinator.test.ts index f03b29737209..88de498d43dc 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.test.ts +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts @@ -4,12 +4,12 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; -import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -describe("CodexResetCreditCoordinator", () => { +describe("ResetCreditCoordinator", () => { it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const keys = yield* Ref.make>([]); const attempts = yield* Ref.make(0); const consume = (key: string) => @@ -31,12 +31,31 @@ describe("CodexResetCreditCoordinator", () => { assert.strictEqual(seen.length, 3); assert.strictEqual(seen[0], seen[1]); assert.notStrictEqual(seen[1], seen[2]); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("starts a fresh attempt after a settled failure", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (key: string) => + Ref.update(keys, (seen) => [...seen, key]).pipe( + Effect.andThen(Effect.fail("cooldown" as const)), + ); + const isSettled = (error: "cooldown") => error === "cooldown"; + + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 2); + assert.notStrictEqual(seen[0], seen[1]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); it.effect("serialises concurrent redemptions on the same account, not per caller", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const release = yield* Deferred.make(); const inFlight = yield* Ref.make(0); const peak = yield* Ref.make(0); @@ -58,12 +77,12 @@ describe("CodexResetCreditCoordinator", () => { yield* Fiber.join(b); assert.strictEqual(yield* Ref.get(peak), 1); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); it.effect("keeps different accounts independent", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const release = yield* Deferred.make(); const peak = yield* Ref.make(0); const inFlight = yield* Ref.make(0); @@ -81,6 +100,6 @@ describe("CodexResetCreditCoordinator", () => { yield* Fiber.join(a); yield* Fiber.join(b); assert.strictEqual(yield* Ref.get(peak), 2); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); }); diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.ts similarity index 70% rename from apps/server/src/provider/Layers/codexResetCredit.ts rename to apps/server/src/provider/Layers/resetCreditCoordinator.ts index 34bd0a77fe27..c481624e7f40 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.ts +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.ts @@ -1,49 +1,44 @@ /** - * Redeeming a Codex reset credit is an account-level action: instances that - * share the directory holding `auth.json` share the credit, so their + * 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. This * service keeps one lock and one pending idempotency key per account key so * overlapping confirmations from any instance queue rather than spending two * credits, and a retry after a timeout re-sends the same attempt. * - * @module provider/Layers/codexResetCredit + * @module provider/Layers/resetCreditCoordinator */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; -/** - * Bounded so a hung app-server cannot hold the account lock forever; the - * timeout interrupts the scoped request, which kills the process, and the - * kept idempotency key makes the user's retry safe. - */ -export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); - interface AccountRedemptionState { readonly lock: Semaphore.Semaphore; readonly pendingKey: Ref.Ref; } -export class CodexResetCreditCoordinator extends Context.Service< - CodexResetCreditCoordinator, +export class ResetCreditCoordinator extends Context.Service< + ResetCreditCoordinator, { /** * Run `consume` under the account's lock with a stable idempotency key. - * The key is cleared only when Codex reports an outcome; a failure - * (timeout included) keeps it so the next attempt is the same attempt. + * 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. */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, + isSettled?: (error: E) => boolean, ) => Effect.Effect; } ->()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} +>()("t3/provider/Layers/resetCreditCoordinator") {} /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { @@ -52,9 +47,7 @@ export const make = Effect.gen(function* () { // Get-or-create through one Ref.modify so two first redemptions for the // same account cannot each install their own lock. - const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* ( - accountKey: string, - ) { + const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { const existing = (yield* Ref.get(statesRef)).get(accountKey); if (existing) return existing; const candidate = { @@ -70,7 +63,7 @@ export const make = Effect.gen(function* () { }); }); - const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) => + const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => Effect.gen(function* () { const state = yield* stateFor(accountKey); return yield* state.lock.withPermits(1)( @@ -78,24 +71,28 @@ export const make = Effect.gen(function* () { const existing = yield* Ref.get(state.pendingKey); const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); yield* Ref.set(state.pendingKey, null); return outcome; }), ); }); - return { redeem } satisfies CodexResetCreditCoordinator["Service"]; + return { redeem } satisfies ResetCreditCoordinator["Service"]; }); -export const layer = Layer.effect(CodexResetCreditCoordinator, make); +export const layer = Layer.effect(ResetCreditCoordinator, make); /** * Self-contained for tests: a counter-backed Crypto so keys are deterministic * and distinct without the platform layer. */ export const layerTest = Layer.effect( - CodexResetCreditCoordinator, + ResetCreditCoordinator, Effect.gen(function* () { let counter = 0; return yield* make.pipe( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 0a2ca28feb73..7933f8e2e962 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -342,6 +342,84 @@ const serviceLayers = (input: { ); describe("ModelManifest service", () => { + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; @@ -363,6 +441,30 @@ describe("ModelManifest service", () => { ), ); + it.live("ignores older remote edits without replacing the current manifest or disk cache", () => { + let remote = { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST); + + remote = { ...REMOTE_MANIFEST, updatedAt: REMOTE_UPDATED_AT }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + remote = { ...REMOTE_MANIFEST, updatedAt: BUNDLED_MODEL_MANIFEST.updatedAt! }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-stale-fetch-test", + response: () => Response.json(remote), + }), + ), + ); + }); + it.live("keeps the bundled manifest when the remote payload is malformed", () => Effect.gen(function* () { const service = yield* make; @@ -457,6 +559,7 @@ describe("ModelManifest service", () => { ), ); assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST); assert.strictEqual(fetchCount, 0); }).pipe( Effect.scoped, @@ -470,3 +573,43 @@ describe("ModelManifest service", () => { ), ); }); + +it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { + const remote: ModelManifestData = { + ...REMOTE_MANIFEST, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [{ range: "=2.0.0", status: "supported" }], + }, + ], + }; + let invalid = false; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + invalid = true; + yield* TestClock.adjust("1 hour"); + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + const rebooted = yield* make; + assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-compatibility-test", + response: () => + Response.json( + invalid + ? { + ...remote, + compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], + } + : remote, + ), + }), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index f5ad15dd8586..d1af1086e750 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL = @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ * files still decode; they count as older than any dated bundle. */ updatedAt: Schema.optional(Schema.String), + compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); @@ -315,6 +317,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. 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. */ @@ -326,6 +330,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -369,7 +374,7 @@ export const make = Effect.gen(function* () { }), ); - const refresh = Effect.fn("ModelManifest.refresh")(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 @@ -377,8 +382,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -397,13 +402,17 @@ export const make = Effect.gen(function* () { Effect.timeout(FETCH_TIMEOUT_MS), Effect.catchCause(() => Effect.succeed(null)), ); - if (fetched === null) return manifest; + // 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. + if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) { + return manifest; + } manifest = fetched; fetchedAtMs = now; yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); return manifest; }); @@ -413,6 +422,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderAuthFlow.test.ts b/apps/server/src/provider/ProviderAuthFlow.test.ts new file mode 100644 index 000000000000..5ea9a0fc2f3b --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.test.ts @@ -0,0 +1,559 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProviderInstanceId, + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthResponse, + type ProviderAuthState, +} from "@t3tools/contracts"; +import * as Fiber from "effect/Fiber"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ProviderAuthFlow from "./ProviderAuthFlow.ts"; + +const instanceId = ProviderInstanceId.make("auth-flow-test"); +const method = { id: "browser", name: "Browser", description: null, type: "agent" as const }; + +it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () => + Effect.gen(function* () { + const discovered = yield* Deferred.make>(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Deferred.await(discovered), + authenticate: () => Effect.void, + logout: Effect.void, + }); + const pending = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isUndefined(pending.methods); + yield* Deferred.succeed(discovered, []); + const ready = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.methods !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepEqual(ready.methods, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeHarness = Effect.gen(function* () { + const approved = yield* Deferred.make(); + const verified = yield* Deferred.make(); + const started = yield* Deferred.make(); + let attempts = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + attempts++; + yield* context.setInteraction( + { + type: "browser", + id: "consent", + url: "https://example.com/login", + requiresConsent: true, + }, + (response) => + response.type === "browser" && response.action === "accept" + ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(approved); + yield* context.verifying; + yield* Deferred.await(verified); + }), + logout: Effect.void, + }); + const phase = (phase: ProviderAuthState["phase"], owner = "owner") => + controller.subscribe(owner).pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + return { controller, phase, started, verified, attempts: () => attempts }; +}); + +it.effect("requires owner consent and provider verification before success", () => + Effect.gen(function* () { + const { controller, phase, started, verified, attempts } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + const response = { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser" as const, action: "accept" as const }, + }; + const other = yield* phase("waiting", "other"); + assert.isNull(other.interaction); + assert.isNull(other.flowId); + assert.isTrue( + (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure", + ); + assert.isTrue( + (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe( + Effect.result, + ))._tag === "Failure", + ); + yield* controller.start("owner"); + assert.strictEqual(attempts(), 1); + yield* controller.respond!("owner", response); + yield* phase("verifying"); + yield* Deferred.succeed(verified, undefined); + assert.strictEqual((yield* phase("succeeded")).phase, "succeeded"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("cancellation rejects late consent and permits another login", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* controller.cancel("owner", state.flowId!); + assert.strictEqual((yield* phase("cancelled")).phase, "cancelled"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + const next = yield* controller.start("other"); + assert.notStrictEqual(next.flowId, state.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("expires pending login and rejects its response", () => + Effect.gen(function* () { + const { controller, started, phase } = yield* makeHarness; + const state = yield* controller.start("owner"); + yield* Deferred.await(started); + yield* TestClock.adjust(300_001); + assert.include((yield* phase("failed")).message ?? "", "expired"); + assert.isTrue( + (yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: "consent", + response: { type: "browser", action: "accept" }, + }).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closes admitted provider processes and blocks new ones while login is pending", () => + Effect.gen(function* () { + const { controller, started } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.start("owner"); + yield* Deferred.await(started); + assert.isTrue(closed); + assert.isTrue( + (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shared credential invalidation closes admitted processes before permitting reuse", () => + Effect.gen(function* () { + const { controller } = yield* makeHarness; + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* controller.invalidate!; + assert.isTrue(closed); + assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + interaction: { type: "terminal", id: "terminal", output: "Log in" }, + response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } }, + }, + { + interaction: { + type: "credentials", + id: "credentials", + fields: [{ name: "token", label: "Token", secret: true }], + }, + response: { type: "credentials", values: { token: "private-token" } }, + }, +] satisfies ReadonlyArray<{ + interaction: ProviderAuthInteraction; + response: ProviderAuthResponse; +}>)( + "keeps $interaction.type responses private and waits for adapter verification", + ({ interaction, response }) => + Effect.gen(function* () { + const received = yield* Deferred.make(); + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "binding" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction(interaction, (response) => + Deferred.succeed(received, response).pipe(Effect.asVoid), + ); + yield* Deferred.await(received); + yield* context.verifying; + return yield* Effect.never; + }), + logout: Effect.void, + }); + const state = yield* controller.start("owner"); + const pending = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.deepStrictEqual(pending.interaction, interaction); + const hidden = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(hidden.interaction); + yield* controller.respond!("owner", { + instanceId, + flowId: state.flowId!, + interactionId: interaction.id, + response, + }); + assert.deepStrictEqual(yield* Deferred.await(received), response); + const verifying = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "verifying"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.isNull(verifying.interaction); + assert.notInclude(verifying.message ?? "", "private-token"); + yield* controller.cancel("owner", state.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("shows a device code only to its owner without treating it as authenticated", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "device" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + context + .setInteraction({ + type: "deviceCode", + id: "code", + url: "https://example.com/device", + userCode: "ABCD-EFGH", + }) + .pipe(Effect.andThen(Effect.never)), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + const waiting = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(waiting.interaction?.type, "deviceCode"); + assert.strictEqual(waiting.authorizationUrl, "https://example.com/device"); + const other = yield* controller + .subscribe("other") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.isNull(other.interaction); + assert.isNull(other.authorizationUrl); + yield* controller.cancel("owner", start.flowId!); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect.each([ + { + failure: Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "authenticate", + detail: "This account cannot sign in. Choose another account.", + }), + ), + message: "This account cannot sign in. Choose another account.", + }, + { + failure: Effect.die(new Error("private-token in native diagnostics")), + message: "Sign-in failed. Start again.", + }, +])("publishes only safe authentication failure text %#", ({ failure, message }) => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "failure" }, + methods: Effect.succeed([method]), + authenticate: () => failure, + logout: Effect.void, + }); + yield* controller.start("owner"); + const failed = yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.strictEqual(failed.message, message); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +const makeBlockingResponseHarness = Effect.gen(function* () { + const entered = yield* Deferred.make(); + const cleanupStarted = yield* Deferred.make(); + const cleanupReleased = yield* Deferred.make(); + const cleanupFinished = yield* Deferred.make(); + const authenticationFinished = yield* Deferred.make(); + let responses = 0; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "blocked-response" }, + methods: Effect.succeed([method]), + authenticate: (_, context) => + Effect.gen(function* () { + yield* context.setInteraction( + { + type: "browser", + id: "blocked", + url: "https://example.com/login", + requiresConsent: true, + }, + () => + Effect.gen(function* () { + responses++; + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(cleanupReleased); + yield* Deferred.succeed(cleanupFinished, undefined); + }), + ), + ), + ); + yield* Deferred.await(authenticationFinished); + }), + logout: Effect.void, + }); + const start = yield* controller.start("owner"); + yield* controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "waiting"), + Stream.runHead, + ); + const input = { + instanceId, + flowId: start.flowId!, + interactionId: "blocked", + response: { type: "browser" as const, action: "accept" as const }, + }; + const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild); + yield* Deferred.await(entered); + return { + controller, + input, + response, + cleanupStarted, + cleanupReleased, + cleanupFinished, + authenticationFinished, + responses: () => responses, + }; +}); + +it.effect.each(["cancel", "logout"] as const)( + "%s interrupts and awaits a blocked adapter response before admitting another sign-in", + (action) => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input)); + assert.include(duplicate.detail, "already in progress"); + assert.equal(harness.responses(), 1); + const stopping = yield* ( + action === "cancel" + ? harness.controller.cancel("owner", harness.input.flowId) + : harness.controller.logout(Effect.void) + ).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + assert.isUndefined(stopping.pollUnsafe()); + const premature = yield* Effect.flip(harness.controller.start("other")); + assert.include(premature.detail, "in progress"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(stopping); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + assert.isFalse(yield* harness.controller.isChangingCredentials!); + assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("timeout interrupts and drains a blocked response before publishing failure", () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* TestClock.adjust(300_001); + yield* Deferred.await(harness.cleanupStarted); + assert.isTrue(yield* harness.controller.isChangingCredentials!); + const duringCleanup = yield* harness.controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.notEqual(duringCleanup.phase, "failed"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const failed = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "failed"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.include(failed.message ?? "", "expired"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect( + "successful authentication drains a still-running response before admitting provider access", + () => + Effect.gen(function* () { + const harness = yield* makeBlockingResponseHarness; + yield* Deferred.succeed(harness.authenticationFinished, undefined); + yield* Deferred.await(harness.cleanupStarted); + const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void)); + assert.include(denied.detail, "changing"); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + const succeeded = yield* harness.controller.subscribe("owner").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(succeeded.phase, "succeeded"); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + yield* harness.controller.withAccess!(Effect.void); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("closing the controller scope interrupts and drains its adapter response", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const harness = yield* makeBlockingResponseHarness.pipe( + Effect.provideService(Scope.Scope, scope), + ); + const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* Deferred.await(harness.cleanupStarted); + assert.isUndefined(closing.pollUnsafe()); + yield* Deferred.succeed(harness.cleanupReleased, undefined); + yield* Fiber.join(closing); + assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished)); + assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("a failed method refresh keeps the last discovered methods", () => + Effect.gen(function* () { + let fail = false; + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "provider", key: "shared-agent" }, + methods: Effect.suspend(() => + fail + ? Effect.fail( + new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }), + ) + : Effect.succeed([method]), + ), + authenticate: () => Effect.void, + logout: Effect.void, + }); + yield* controller.refreshMethods!; + fail = true; + yield* controller.refreshMethods!; + const state = yield* controller + .subscribe("owner") + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + assert.deepEqual(state.methods, [method]); + assert.strictEqual(state.message, "interrupted"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rebuilding a controller leaves sessions it admitted to their owners", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope)); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + yield* Scope.close(scope, Exit.void); + assert.isFalse(closed); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("profile import stops owned sessions and gates access until credentials are saved", () => + Effect.gen(function* () { + const controller = yield* ProviderAuthFlow.make({ + instanceId, + credentialBinding: { owner: "t3", key: "handoff-binding" }, + methods: Effect.succeed([method]), + authenticate: () => Effect.void, + logout: Effect.void, + }); + let closed = false; + yield* controller.withAccess!( + Effect.addFinalizer(() => + Effect.sync(() => { + closed = true; + }), + ), + ); + const writing = yield* Deferred.make(); + const release = yield* Deferred.make(); + const imported = yield* controller.adoptCredentials!( + Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))), + Effect.sync(() => { + assert.isTrue(closed); + }), + ).pipe(Effect.forkChild); + yield* Deferred.await(writing); + const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe( + Effect.result, + ); + assert.strictEqual(denied._tag, "Failure"); + yield* Deferred.succeed(release, undefined); + assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded"); + assert.strictEqual( + yield* controller.withAccess!(Effect.succeed("new credential process")), + "new credential process", + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ProviderAuthFlow.ts b/apps/server/src/provider/ProviderAuthFlow.ts new file mode 100644 index 000000000000..f69de4b6bc37 --- /dev/null +++ b/apps/server/src/provider/ProviderAuthFlow.ts @@ -0,0 +1,527 @@ +import { + ProviderSetupError, + type ProviderAuthInteraction, + type ProviderAuthMethod, + type ProviderAuthResponse, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type * as ProviderAuthService from "./Services/ProviderAuthService.ts"; + +export interface ProviderAuthFlowContext { + readonly flowId: string; + /** The lifetime timeout interrupts authenticate before publishing its expired result. */ + readonly expiresAt: number; + readonly returnUrl?: string; + readonly callbackMode?: "server" | "client"; + readonly setInteraction: ( + interaction: ProviderAuthInteraction, + respond?: (response: ProviderAuthResponse) => Effect.Effect, + complete?: (callbackUrl: string) => Effect.Effect, + ) => Effect.Effect; + readonly verifying: Effect.Effect; +} + +const isSetupError = Schema.is(ProviderSetupError); + +const failureMessage = (cause: Cause.Cause) => { + const error = Cause.findErrorOption(cause); + return Option.isSome(error) && isSetupError(error.value) + ? error.value.detail + : "Sign-in failed. Start again."; +}; + +interface Flow { + readonly id: string; + readonly owner: string; + readonly expiresAt: number; + complete?: ((callbackUrl: string) => Effect.Effect) | undefined; + fiber?: Fiber.Fiber; + responseFiber?: Fiber.Fiber; + respond: + | ((response: ProviderAuthResponse) => Effect.Effect) + | undefined; +} + +/** Adapters do login and credential handling; this owns client consent and flow lifetime. */ +export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { + readonly instanceId: ProviderInstanceId; + readonly credentialBinding: NonNullable< + ProviderAuthService.ProviderAuthController["credentialBinding"] + >; + readonly methods: Effect.Effect, ProviderSetupError>; + readonly defaultMethodId?: string; + /** Stored account profiles can change the advertised methods after auth/logout. */ + readonly refreshMethodsAfterAuth?: boolean; + /** Fail with ProviderSetupError containing safe text for the user, never native token data. */ + readonly authenticate: ( + methodId: string, + context: ProviderAuthFlowContext, + ) => Effect.Effect; + readonly logout: Effect.Effect; + readonly timeoutMs?: number; +}) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + const timeoutMs = options.timeoutMs ?? 300_000; + const empty: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + interaction: null, + credentialOwner: options.credentialBinding.owner, + }; + const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty }); + let active: Flow | undefined; + let operation: "idle" | "auth" | "stopping" | "closed" = "idle"; + const sessions = new Set(); + const stopOwnedSessions = Effect.suspend(() => + Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { + discard: true, + concurrency: "unbounded", + }), + ); + const publish = (flow: Flow, patch: Partial) => + Effect.suspend(() => + active === flow + ? SubscriptionRef.update(snapshot, (current) => ({ + owner: flow.owner, + state: { ...current.state, ...patch }, + })) + : Effect.void, + ); + + const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) { + if ( + operation !== "auth" || + !active || + active.owner !== owner || + active.id !== id || + (yield* Clock.currentTimeMillis) >= active.expiresAt + ) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); + + // Method discovery does not start a sign-in flow. + const refreshMethods = options.methods.pipe( + Effect.flatMap((methods) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods }, + })), + ), + // Keep the last known methods when a refresh fails or is interrupted. + Effect.catch((error) => + SubscriptionRef.update(snapshot, (current) => ({ + ...current, + state: { ...current.state, methods: current.state.methods ?? [], message: error.detail }, + })), + ), + ); + yield* refreshMethods.pipe(Effect.forkIn(scope)); + + const controller: ProviderAuthService.ProviderAuthController = { + credentialBinding: options.credentialBinding, + adoptCredentials: (update, stopSessions) => + Effect.gen(function* () { + yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "import", + detail: "Finish or cancel the existing sign-in first.", + }); + operation = "stopping"; + }), + ); + const result = yield* Effect.gen(function* () { + yield* stopOwnedSessions; + yield* stopSessions; + yield* update; + yield* refreshMethods; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + message: Exit.isSuccess(result) ? null : failureMessage(result.cause), + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), + refreshMethods, + invalidate: lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") return; + yield* stopOwnedSessions; + yield* SubscriptionRef.set(snapshot, { + owner: null, + state: { + ...empty, + methods: snapshot.value.state.methods ?? [], + message: "This provider's shared sign-in changed.", + }, + }); + }), + ), + isChangingCredentials: Effect.sync(() => operation !== "idle"), + withAccess: (task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const child = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "session", + detail: "Provider sign-in is changing. Try again after it finishes.", + }); + const child = yield* Scope.make(); + sessions.add(child); + yield* Scope.addFinalizer( + child, + Effect.sync(() => { + sessions.delete(child); + }), + ); + yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); + return child; + }), + ); + const fiber = yield* restore(task).pipe( + Effect.provideService(Scope.Scope, child), + Effect.forkIn(child), + ); + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.onExit((result) => + Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, + ), + ); + }), + ), + start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) => + lock.withPermit( + Effect.gen(function* () { + if (operation === "auth" && active?.owner === owner) return snapshot.value.state; + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Provider setup is already in progress.", + }); + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not start sign-in. Try again.", + }), + ), + ); + const flow: Flow = { + id, + owner, + expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs, + respond: undefined, + }; + active = flow; + operation = "auth"; + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: "starting", + flowId: id, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)), + message: "Starting sign-in.", + }; + yield* SubscriptionRef.set(snapshot, { owner, state }); + flow.fiber = yield* Effect.gen(function* () { + const methods = yield* options.methods; + yield* publish(flow, { methods }); + const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id; + if (!methodId || !methods.some((method) => method.id === methodId)) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "The provider did not advertise this sign-in method.", + }); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + yield* options.authenticate(methodId, { + flowId: id, + expiresAt: flow.expiresAt, + ...(returnUrl ? { returnUrl } : {}), + ...(callbackMode ? { callbackMode } : {}), + setInteraction: (interaction, respond, complete) => + Effect.gen(function* () { + if (active !== flow) return; + flow.respond = respond; + flow.complete = complete; + yield* publish(flow, { + phase: "waiting", + interaction, + authorizationUrl: + interaction.type === "browser" || interaction.type === "deviceCode" + ? interaction.url + : null, + message: "Complete sign-in to continue.", + }); + }), + verifying: Effect.gen(function* () { + flow.respond = undefined; + flow.complete = undefined; + yield* publish(flow, { + phase: "verifying", + interaction: null, + authorizationUrl: null, + message: "Checking provider sign-in.", + }); + }), + }); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Sign-in expired. Start again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + Effect.gen(function* () { + const finishing = yield* lock.withPermit( + Effect.sync(() => { + if (active !== flow) return false; + operation = "stopping"; + return true; + }), + ); + if (!finishing) return; + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + yield* lock.withPermit( + Effect.gen(function* () { + if (active !== flow) return; + if (options.refreshMethodsAfterAuth) yield* refreshMethods; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Sign-in complete." + : failureMessage(result.cause), + }); + active = undefined; + operation = "idle"; + }), + ); + }), + ), + Effect.interruptible, + Effect.forkIn(scope), + ); + return state; + }).pipe(Effect.uninterruptible), + ), + respond: (owner, input) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const { flow, fiber, ready } = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + !interaction || + interaction.id !== input.interactionId || + interaction.type !== input.response.type || + !flow.respond + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "This sign-in interaction is no longer available.", + }); + if (flow.responseFiber) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "respond", + detail: "A sign-in response is already in progress.", + }); + const ready = yield* Deferred.make(); + const callback = flow.respond; + const fiber = yield* Deferred.await(ready).pipe( + Effect.andThen(Effect.suspend(() => callback(input.response))), + Effect.interruptible, + Effect.forkIn(scope), + ); + flow.responseFiber = fiber; + return { flow, fiber, ready }; + }), + ); + yield* Deferred.succeed(ready, undefined); + yield* restore(Fiber.join(fiber)).pipe( + Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)), + Effect.ensuring( + lock.withPermit( + Effect.sync(() => { + if (flow.responseFiber === fiber) delete flow.responseFiber; + }), + ), + ), + ); + return snapshot.value.state; + }), + ), + complete: (owner, input) => + lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, input.flowId); + const interaction = snapshot.value.state.interaction; + if ( + snapshot.value.state.phase !== "waiting" || + interaction?.type !== "browser" || + !interaction.acceptsCallback || + !flow.complete + ) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "This sign-in does not accept a redirect URL.", + }); + yield* flow.complete(input.callbackUrl); + return snapshot.value.state; + }), + ), + cancel: (owner, id) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id); + yield* publish(flow, { + phase: "cancelled", + interaction: null, + authorizationUrl: null, + expiresAt: null, + message: "Sign-in cancelled.", + }); + active = undefined; + operation = "stopping"; + return flow; + }), + ); + if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }).pipe(Effect.uninterruptible), + logout: (stopSessions) => + Effect.gen(function* () { + const flow = yield* lock.withPermit( + Effect.gen(function* () { + if (operation !== "idle" && operation !== "auth") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Provider setup is already stopping.", + }); + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions)); + const message = yield* options.logout; + if (options.refreshMethodsAfterAuth) yield* refreshMethods; + return message; + }).pipe(Effect.exit); + const state: ProviderAuthState = { + ...empty, + methods: snapshot.value.state.methods ?? [], + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) + ? (result.value ?? "Signed out.") + : "Could not sign out. Try again.", + }; + yield* lock.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + operation = "idle"; + }), + ); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }).pipe(Effect.uninterruptible), + subscribe: (owner) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((current) => + current.owner === null || current.owner === owner + ? current.state + : { + ...current.state, + flowId: null, + authorizationUrl: null, + interaction: null, + expiresAt: null, + message: active + ? "Sign-in is in progress in another client." + : current.state.message, + }, + ), + ), + }; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = active; + active = undefined; + if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber); + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + // Settings edits rebuild instances; admitted sessions (including those of + // peers sharing this binding) end with their own scopes, not this one. + }), + ); + return controller; +}); diff --git a/apps/server/src/provider/ProviderCredentialStore.test.ts b/apps/server/src/provider/ProviderCredentialStore.test.ts new file mode 100644 index 000000000000..0a5ad906e126 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; + +it.effect("isolates provider bindings and preserves opaque credentials", () => + Effect.gen(function* () { + const data = new Map(); + const secretStore = ServerSecretStore.of({ + get: (name) => Effect.sync(() => Option.fromUndefinedOr(data.get(name))), + set: (name, value) => + Effect.sync(() => { + data.set(name, value); + }), + remove: (name) => + Effect.sync(() => { + data.delete(name); + }), + create: () => Effect.die("unused"), + getOrCreateRandom: () => Effect.die("unused"), + }); + const a = yield* ProviderCredentialStore.make("cursor", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const b = yield* ProviderCredentialStore.make("cursor", "work").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const c = yield* ProviderCredentialStore.make("other", "../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + const bytes = Uint8Array.from([0, 255, 128, 1]); + yield* a.set(bytes); + assert.deepStrictEqual(Option.getOrThrow(yield* a.get), bytes); + assert.isTrue(Option.isNone(yield* b.get)); + assert.isTrue(Option.isNone(yield* c.get)); + assert.isFalse(a.binding.key.includes("/")); + const long = yield* ProviderCredentialStore.make("a".repeat(64), "b".repeat(64)).pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.isBelow(long.binding.key.length, 255); + const delimiter = yield* ProviderCredentialStore.make("cur", "sor../../personal").pipe( + Effect.provideService(ServerSecretStore, secretStore), + ); + assert.notStrictEqual(delimiter.binding.key, a.binding.key); + yield* a.remove; + assert.isTrue(Option.isNone(yield* a.get)); + }), +); diff --git a/apps/server/src/provider/ProviderCredentialStore.ts b/apps/server/src/provider/ProviderCredentialStore.ts new file mode 100644 index 000000000000..259b441654c9 --- /dev/null +++ b/apps/server/src/provider/ProviderCredentialStore.ts @@ -0,0 +1,21 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +/** A provider binding stores opaque bytes; only its adapter decodes or refreshes them. */ +export const make = Effect.fn("ProviderCredentialStore.make")(function* ( + driver: string, + bindingId: string, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + // Hash the tuple so arbitrary bindings cannot escape or exceed a filename. + const key = `provider-auth-${NodeCrypto.createHash("sha256") + .update(`${driver.length}:${driver}${bindingId}`) + .digest("hex")}`; + return { + binding: { owner: "t3" as const, key }, + get: secrets.get(key), + set: (credentials: Uint8Array) => secrets.set(key, credentials), + remove: secrets.remove(key), + }; +}); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index a5c092233052..059a2508e40f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -74,6 +74,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; + /** Invalidate T3-owned discovery caches before an explicit provider refresh. */ + readonly invalidateCaches?: Effect.Effect; /** * Redeem one banked rate-limit reset credit on the signed-in account, then * re-probe so the snapshot reflects the cleared windows. Account-level, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..10f8413b5a13 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -20,6 +20,15 @@ describe("buildRuntimeInstructions", () => { ).toContain("through the Codex harness, as custom model with high reasoning effort."); }); + it("names the model by display name and slug when they differ", () => { + expect( + buildRuntimeInstructions({ harness: "Codex", model: "gpt-5.4", modelName: "GPT-5.4" }), + ).toContain("through the Codex harness, as GPT-5.4 (model slug: gpt-5.4)."); + expect( + buildRuntimeInstructions({ harness: "Codex", model: "my-model", modelName: "my-model" }), + ).toContain("through the Codex harness, as my-model."); + }); + it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => { const instructions = buildRuntimeInstructions({ harness: "Cursor", model }); expect(instructions).toContain("through the Cursor harness."); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index ce74d1f48f16..fe649d9ca09e 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,16 +2,23 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. `; -/** Shared runtime context; omit model and effort when the harness manages them dynamically. */ +/** + * Shared runtime context; omit model and effort when the harness manages them dynamically. + * `modelName` is the display name users see in the model picker; `model` is the slug. + */ export function buildRuntimeInstructions(runtime: { readonly harness: string; readonly model?: string | undefined; + readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); + const modelName = toSingleLine(runtime.modelName ?? ""); const effort = toSingleLine(runtime.reasoningEffort ?? ""); - const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; + const modelLabel = + modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; + const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; return `In case you're asked: you are running in T3 Turbo through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } diff --git a/apps/server/src/provider/Services/ProviderAuthService.ts b/apps/server/src/provider/Services/ProviderAuthService.ts index ea89edf78af5..273f1b5337ee 100644 --- a/apps/server/src/provider/Services/ProviderAuthService.ts +++ b/apps/server/src/provider/Services/ProviderAuthService.ts @@ -1,12 +1,43 @@ -import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; +import type { + ChatGptReconnectProfile, + ChatGptTransferredProfile, + ProviderAuthRespondInput, + ProviderAuthStartInput, + ProviderAuthState, + ProviderInstanceId, + ProviderSetupError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type * as Scope from "effect/Scope"; export interface ProviderAuthController { + /** Equal keys mean these instances share credentials on this environment. */ + readonly credentialBinding?: { readonly owner: "provider" | "t3"; readonly key: string }; + readonly reconnectProfile?: ( + methodId: string, + ) => Effect.Effect; + readonly importProfile?: ( + profile: ChatGptTransferredProfile, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly adoptCredentials?: ( + update: Effect.Effect, + stopSessions: Effect.Effect, + ) => Effect.Effect; + readonly isChangingCredentials?: Effect.Effect; + readonly invalidate?: Effect.Effect; + readonly refreshMethods?: Effect.Effect; + readonly withAccess?: ( + task: Effect.Effect, + ) => Effect.Effect; readonly start: ( ownerSessionId: string, stopSessions?: Effect.Effect, + methodId?: string, + returnUrl?: string, + callbackMode?: "server" | "client", ) => Effect.Effect; readonly complete: ( ownerSessionId: string, @@ -16,6 +47,10 @@ export interface ProviderAuthController { ownerSessionId: string, flowId: string, ) => Effect.Effect; + readonly respond?: ( + ownerSessionId: string, + input: ProviderAuthRespondInput, + ) => Effect.Effect; /** The controller closes process admission before it stops routed sessions. */ readonly logout: ( stopSessions: Effect.Effect, @@ -29,14 +64,24 @@ interface ProviderAuthTarget { } export interface ProviderAuthServiceShape { + readonly reconnectProfile: ( + input: ProviderAuthTarget & { methodId: string }, + ) => Effect.Effect; + readonly importProfile: ( + input: ProviderAuthTarget & { profile: ChatGptTransferredProfile }, + ) => Effect.Effect; readonly start: ( - input: ProviderAuthTarget, + input: ProviderAuthStartInput, ownerSessionId: string, ) => Effect.Effect; readonly complete: ( input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string }, ownerSessionId: string, ) => Effect.Effect; + readonly respond: ( + input: ProviderAuthRespondInput, + ownerSessionId: string, + ) => Effect.Effect; readonly cancel: ( input: ProviderAuthTarget & { readonly flowId: string }, ownerSessionId: string, diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 9dbafd3e804e..1b5c47ec1d62 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -70,7 +70,10 @@ export interface ProviderSessionDirectoryShape { ProviderSessionDirectoryPersistenceError >; - readonly listBindings: () => Effect.Effect< + /** `excludeStopped` skips stopped rows in the query, not after decoding. */ + readonly listBindings: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError >; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 45a5cadb9a31..dde5979c0f56 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -824,6 +824,54 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps one answer when an earlier tool reports progress mid-stream", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9))); + // The coalesced progress tick emits nothing, and neither the completion + // nor a repeated one splits the markdown table across items. + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "AssistantItemStarted", + "ContentDelta", + "ContentDelta", + "ToolCallUpdated", + "ContentDelta", + "ToolCallUpdated", + "ContentDelta", + "AssistantItemCompleted", + ]); + const itemIds = new Set( + notes.flatMap((note) => + note._tag === "ContentDelta" || + note._tag === "AssistantItemStarted" || + note._tag === "AssistantItemCompleted" + ? [note.itemId] + : [], + ), + ); + expect(itemIds.size).toBe(1); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 77517c44ea9b..0d0b81e910a3 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -40,6 +40,8 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +const MAX_SHOWN_TOOL_CALL_IDS = 256; + interface AcpToolCallTrackedState { readonly state: AcpToolCallState; readonly lastEmittedDetailLength: number | undefined; @@ -334,6 +336,9 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + // Recently shown tool calls. A late update to a finished call is not a new + // boundary in the answer, although its progress state is gone. + const shownToolCallIds = new Set(); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -525,6 +530,7 @@ export const make = ( modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params: notification, @@ -774,17 +780,16 @@ export const make = ( resumePayload, acp.agent.resumeSession(resumePayload).pipe( Effect.timeoutOption(options.sessionLoadTimeout ?? defaultSessionLoadTimeout), - Effect.flatMap((result) => - Option.isSome(result) - ? Effect.succeed(result.value) - : Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/resume", - detail: "session/resume timed out waiting for the agent response.", - cause: undefined, - }), - ), + Effect.flatMap( + Effect.fromOption( + () => + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/resume", + detail: "session/resume timed out waiting for the agent response.", + cause: undefined, + }), + ), ), ), ); @@ -828,19 +833,16 @@ export const make = ( ).pipe( Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), Effect.timeoutOption(sessionLoadTimeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/load", - detail: "session/load timed out waiting for RPC response or replay idle gap", - cause: undefined, - }), - ), - onSome: Effect.succeed, - }), + Effect.flatMap( + Effect.fromOption( + () => + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), ), Effect.tap((result) => logRequest({ @@ -1052,12 +1054,13 @@ export const make = ( ), (activePrompt) => Fiber.join(activePrompt.fiber).pipe( - Effect.catchCause((cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) - ? Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse) - : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => + options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), + () => + Effect.succeed({ + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse), ), Effect.tap(() => closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), @@ -1178,6 +1181,7 @@ const handleSessionUpdate = ({ modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params, @@ -1186,6 +1190,7 @@ const handleSessionUpdate = ({ readonly modeStateRef: Ref.Ref; readonly configOptionsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; + readonly shownToolCallIds: Set; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -1202,11 +1207,7 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - yield* closeActiveAssistantSegment({ - queue, - assistantSegmentRef, - }); - const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1228,11 +1229,22 @@ const handleSessionUpdate = ({ skippedSinceEmit: decision.skippedSinceEmit, }); } - return [{ merged: nextToolCall, decision }, next] as const; + return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; }); if (!decision.emit) { continue; } + // A new tool call is a boundary in the prose. Progress on a call that + // is already shown, such as a background command finishing, is not. + if (!shownToolCallIds.has(merged.toolCallId)) { + shownToolCallIds.add(merged.toolCallId); + // Only recent calls get late updates; keep a long session bounded. + if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { + shownToolCallIds.delete(shownToolCallIds.values().next().value!); + } + // A call still running is already on screen, even if it aged out. + if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); + } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", toolCall: merged, diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index aee10782a613..7d6cfb1e2620 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, type RuntimeMode, @@ -15,7 +14,6 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { - ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES, antigravityPermissionMode, applyAntigravityAcpModelSelection, buildAntigravityPrompt, @@ -324,6 +322,41 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect.each([ + { name: "archive.zip", mimeType: "application/zip" }, + { name: "clip.mp4", mimeType: "video/mp4" }, + { name: "recording.aiff", mimeType: "audio/aiff" }, + { name: "large.pdf", mimeType: "application/pdf", sizeBytes: 75_000_000 }, + { name: "large.txt", mimeType: "text/plain", sizeBytes: 1024 * 1024 + 1 }, + { name: "large.wav", mimeType: "audio/wav", sizeBytes: 20 * 1024 * 1024 + 1 }, + ])("keeps $name as a file path without reading or spending the native media budget", (file) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const attachment = { ...textAttachment, sizeBytes: 50 * 1024 * 1024, ...file }; + const upload = yield* fixture.write(attachment, ""); + yield* fixture.fs.truncate(upload.filePath, attachment.sizeBytes); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + const input = `Inspect the file at ${upload.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [attachment, pdfAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => { + throw new Error("Path attachments must not be read into the prompt"); + }, + }), + ); + expect(prompt).toEqual([ + { type: "text", text: input }, + { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" }, + ]); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); @@ -378,8 +411,6 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { it.effect.each([ { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, - { ...textAttachment, name: "archive.zip", mimeType: "application/zip" }, - { ...textAttachment, name: "recording.aiff", mimeType: "audio/aiff" }, ] satisfies ReadonlyArray)( "rejects $name instead of silently dropping it from a valid prompt", (attachment) => @@ -400,32 +431,25 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([ - { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 }, - { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }, - { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }, - ])( - "rejects oversized $attachment.name using file size instead of upload metadata", - ({ attachment, bytes }) => - Effect.gen(function* () { - const fixture = yield* makeAttachmentFixture(); - const upload = yield* fixture.write(attachment, ""); - yield* fixture.fs.truncate(upload.filePath, bytes); - const error = yield* buildAntigravityPrompt({ - input: "Read this attachment.", - attachments: [attachment], - attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining(`'${attachment.name}' is too large`), - }); - }), + it.effect("rejects oversized images using file size instead of upload metadata", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const upload = yield* fixture.write(imageAttachment, ""); + yield* fixture.fs.truncate(upload.filePath, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1); + const error = yield* buildAntigravityPrompt({ + input: "Read this attachment.", + attachments: [imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: expect.stringContaining("'screen.png' is too large"), + }); + }), ); - it.effect("accepts 50 MiB in total but rejects one byte more across files", () => + it.effect("keeps PDF overflow on the file-path route", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); const secondAttachment = { @@ -435,25 +459,57 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }; const first = yield* fixture.write(pdfAttachment, ""); const second = yield* fixture.write(secondAttachment, ""); - yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2); const input = { - input: undefined, + input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], attachmentsDir: fixture.attachmentsDir, }; const prompt = yield* buildAntigravityPrompt(input); expect(prompt).toEqual([ - { type: "resource_link", uri: first.uri, name: "report.pdf", mimeType: "application/pdf" }, - { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" }, + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + { + type: "resource_link", + uri: second.uri, + name: "second.pdf", + mimeType: "application/pdf", + }, ]); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1); - const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1); + expect(yield* buildAntigravityPrompt(input)).toEqual([ + { type: "text", text: input.input }, + { + type: "resource_link", + uri: first.uri, + name: "report.pdf", + mimeType: "application/pdf", + }, + ]); + }), + ); + + it.effect("still rejects images when the native budget is full", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pdf = yield* fixture.write(pdfAttachment, ""); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024); + yield* fixture.write(imageAttachment, new Uint8Array([1])); + const error = yield* buildAntigravityPrompt({ + input: "Inspect both attachments.", + attachments: [pdfAttachment, imageAttachment], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining("'second.pdf' is too large"), + errorMessage: expect.stringContaining("'screen.png' is too large"), }); }), ); @@ -463,7 +519,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const fixture = yield* makeAttachmentFixture(); const pdf = yield* fixture.write(pdfAttachment, ""); const text = yield* fixture.write(textAttachment, "a"); - yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1); const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index f2f370068181..8f2877330bbe 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -243,10 +243,14 @@ const TEXT_FILE_EXTENSIONS = new Set([ ".ini", ".conf", ]); -export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; +const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; -/** Sends uploads as native ACP content instead of workspace path hints. */ +/** + * Sends supported uploads as native ACP content. Other files, and native + * candidates over their limits, reach the agent through the saved path + * ProviderService puts in the text block. + */ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; @@ -280,7 +284,9 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi (mimeType.startsWith("text/") || TEXT_MIME_TYPES.has(mimeType) || TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); - if (!image && !audio && !pdf && !textFile) { + 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.`, ); @@ -303,26 +309,30 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); - if (isPastedText) { - if (info.type !== "File") { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Could not read attachment '${attachment.name}'.`, - ); - } - continue; + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); } + if (isPathOnly) continue; const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES : audio ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf - ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + ? MAX_TOTAL_ATTACHMENT_BYTES : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; + if ( + attachment.type === "file" && + (size > limit || totalBytes + size > MAX_TOTAL_ATTACHMENT_BYTES) + ) { + continue; + } totalBytes += size; - if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + if (size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`, + `Image '${attachment.name}' is too large. Antigravity accepts images up to 10 MiB and 50 MiB of native attachments per message.`, ); } const uri = yield* path.toFileUrl(attachmentPath).pipe( diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index 07d66065d9ee..f20640bac0a0 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -43,10 +43,10 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession ); /** - * Removes every per-process runtime temp directory under the profile. 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. - * Only the profile-owned directory is touched. The system temp directory + * Removes every per-process runtime temp directory under an instance's root. + * 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. Only T3-owned directories are touched. The system temp directory * belongs to other programs and Windows does not lock data files, so sweeping * it could gut a live extraction. */ diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 01fe516454ea..c2402c87c3ca 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -1,6 +1,8 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { @@ -11,6 +13,7 @@ import { import { describe, expect, it } from "@effect/vitest"; 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 Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -36,7 +39,7 @@ import { makeAntigravityStdoutTransform, parseAntigravityAuthorizationUrl, prepareAntigravityProfile, - resolveAntigravityProfileDirectory, + resolveAntigravityInstanceDirectories, } from "./antigravityAuthSupport.ts"; const authorizationUrl = @@ -246,20 +249,44 @@ describe("Antigravity process environment", () => { } }); - it("keeps accounts separate even when instance IDs differ only by case", () => { - const first = resolveAntigravityProfileDirectory( - "/userdata", - ProviderInstanceId.make("antigravity"), - ); - const second = resolveAntigravityProfileDirectory( - "/userdata", - ProviderInstanceId.make("Antigravity"), - ); - expect(first.toLowerCase()).not.toBe(second.toLowerCase()); - expect( - resolveAntigravityProfileDirectory("/userdata", ProviderInstanceId.make("antigravity")), - ).toBe(first); - }); + it.effect("keeps accounts separate even when instance IDs differ only by case", () => + Effect.gen(function* () { + const first = yield* resolveAntigravityInstanceDirectories( + "/userdata", + ProviderInstanceId.make("antigravity"), + ); + const second = yield* resolveAntigravityInstanceDirectories( + "/userdata", + ProviderInstanceId.make("Antigravity"), + ); + // Existing sign-ins live at this path; it must not move. + expect(first.profile).toBe( + "/userdata/providers/antigravity/ac0a3dfd6dddb20962cecff6ee5fe65e19d3923be20e52c5ab52ff877f7e4c32", + ); + expect(first.profile.toLowerCase()).not.toBe(second.profile.toLowerCase()); + expect(first.runtimeTemp.toLowerCase()).not.toBe(second.runtimeTemp.toLowerCase()); + }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerPosix))), + ); + + it.effect("keeps the unpacked Windows runtime under MAX_PATH for long user names", () => + Effect.gen(function* () { + const path = yield* Path.Path; + // Deepest member of the official agy_acp_server_1.1.1 windows-x86_64 bundle. + const deepestMember = + "google3\\cloud\\developer_experience\\antigravity_extensions\\acp_server\\_private__agy_acp_server_bin.lazy_imports_info.json"; + const directories = yield* resolveAntigravityInstanceDirectories( + "C:\\Users\\a-twenty-char-person\\.t3\\userdata", + ProviderInstanceId.make("antigravity"), + ); + const extracted = (tempDirectory: string) => + path.join(tempDirectory, "run-AbC123", "_MEI000012ab2", deepestMember); + // MAX_PATH is 260 including the terminating NUL. + expect(extracted(directories.runtimeTemp).length).toBeLessThan(260); + expect( + extracted(path.join(directories.profile, "antigravity-acp", "tmp")).length, + ).toBeGreaterThanOrEqual(260); + }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerWin32))), + ); }); describe("Antigravity authorization URL", () => { diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index b48368ad2a1d..6eaa4ec7a497 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,13 +1,12 @@ -import * as NodeCrypto from "node:crypto"; // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. import * as NodeFSP from "node:fs/promises"; -// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. -import * as NodePath from "node:path"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; @@ -185,19 +184,32 @@ export function isAntigravitySignInRequiredError(error: unknown): boolean { ); } -/** Keeps case-sensitive instance IDs separate on case-insensitive filesystems. */ -export function resolveAntigravityProfileDirectory( - stateDir: string, - instanceId: ProviderInstanceId, -): string { - const directoryName = NodeCrypto.createHash("sha256").update(instanceId).digest("hex"); - return NodePath.join(stateDir, "providers", "antigravity", directoryName); +export interface AntigravityInstanceDirectories { + /** GEMINI_HOME for the agent. Holds the instance's Google sign-in. */ + readonly profile: string; + /** + * Parent of the per-process directories the agent unpacks into. 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. + */ + readonly runtimeTemp: string; } -/** Parent of the per-process runtime temp directories inside a profile. */ -export function resolveAntigravityRuntimeTempDirectory(profileDirectory: string): string { - return NodePath.join(profileDirectory, "antigravity-acp", "tmp"); -} +/** Hashes the instance ID so case-only differences stay separate on case-insensitive filesystems. */ +export const resolveAntigravityInstanceDirectories = Effect.fn( + "resolveAntigravityInstanceDirectories", +)(function* (stateDir: string, instanceId: ProviderInstanceId) { + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + const key = Encoding.encodeHex( + yield* crypto.digest("SHA-256", new TextEncoder().encode(instanceId)), + ); + return { + profile: path.join(stateDir, "providers", "antigravity", key), + runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)), + } satisfies AntigravityInstanceDirectories; +}); function quoteBrowserArgument(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; @@ -265,9 +277,7 @@ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(functio yield* Effect.gen(function* () { const existing = yield* fs.readLink(link).pipe( Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), - ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined), ); if (existing === target) return; if (existing !== undefined) { @@ -300,6 +310,8 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( readonly auth?: AntigravityAuthConfig; /** Home the agent expands `~` against. Defaults to the launch environment's. */ readonly userHome?: string; + /** Parent of per-process temp directories. Defaults to one inside the profile. */ + readonly tempDirectory?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; @@ -337,7 +349,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( const geminiHome = path.resolve(input.profileDirectory); const acpDirectory = path.join(geminiHome, "antigravity-acp"); - const tempDirectory = resolveAntigravityRuntimeTempDirectory(geminiHome); + const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp"); const profile: AntigravityProfile = { platform, geminiHome, diff --git a/apps/server/src/provider/cursorCredentialStore.test.ts b/apps/server/src/provider/cursorCredentialStore.test.ts new file mode 100644 index 000000000000..f212ce110ea6 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + CursorKeychainTimeoutError, + makeCachedCursorAccessTokenReader, +} from "./cursorCredentialStore.ts"; + +describe("Cursor Keychain reader", () => { + it("shares concurrent reads and rechecks after the cache expires", async () => { + let reads = 0; + let time = 0; + const read = makeCachedCursorAccessTokenReader( + async () => { + reads++; + return `token-${reads}`; + }, + () => time, + ); + assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); + assert.strictEqual(await read(), "token-1"); + assert.strictEqual(reads, 1); + time = 5 * 60_000; + assert.strictEqual(await read(), "token-2"); + }); + + it("gives up on an unanswered prompt and reuses it on the next read", async () => { + let reads = 0; + let allow: (token: string) => void = () => {}; + const read = makeCachedCursorAccessTokenReader( + () => { + reads++; + return new Promise((resolve) => { + allow = resolve; + }); + }, + () => 0, + 1, + ); + const error = await read().catch((cause: unknown) => cause); + assert.instanceOf(error, CursorKeychainTimeoutError); + const retry = read(); + allow("token"); + assert.strictEqual(await retry, "token"); + assert.strictEqual(reads, 1); + }); +}); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts new file mode 100644 index 000000000000..2857683e4821 --- /dev/null +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -0,0 +1,55 @@ +import * as NodeModule from "node:module"; + +const CACHE_MS = 5 * 60_000; + +const requireForKeyring = NodeModule.createRequire(import.meta.url); + +/** Rejected when nobody answers the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Error { + constructor() { + super("Timed out waiting for Keychain access."); + } +} + +/** + * Share one Keychain request across usage history and limits in this server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so callers give up after `timeoutMs`. The read stays in + * flight: the next call reuses it instead of stacking a second prompt, and picks + * up the token once someone allows access. + */ +export function makeCachedCursorAccessTokenReader( + read: () => Promise, + now: () => number = Date.now, + timeoutMs = 30_000, +): () => Promise { + let cached: { token: string; until: number } | null = null; + let pending: Promise | null = null; + return () => { + if (cached && cached.until > now()) return Promise.resolve(cached.token); + pending ??= read() + .then((token) => { + cached = token ? { token, until: now() + CACHE_MS } : null; + return token; + }) + .finally(() => { + pending = null; + }); + const deadline = AbortSignal.timeout(timeoutMs); + return Promise.race([ + pending, + new Promise((_, reject) => { + deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { + once: true, + }); + }), + ]); + }; +} + +/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ +export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { + const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; +}); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index c180412ec42d..119c577474c8 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -252,6 +252,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* Effect.forever( getRefreshInterval.pipe( Effect.flatMap((refreshInterval) => + // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races the interval against a settings-change signal, not a timeout Effect.raceFirst( Effect.sleep( Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 8befdd86ec52..1c5f900849d4 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,16 +1,92 @@ { "version": 1, - "updatedAt": "2026-09-22T17:51:55Z", + "updatedAt": "2026-09-29T20:20:00Z", + "compatibility": [ + { + "driver": "codex", + "t3CodeRange": ">=0.0.44", + "recommendedRange": ">=0.159.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "codex", + "t3CodeRange": ">=0.0.42 <0.0.44", + "recommendedRange": ">=0.156.0", + "ranges": [ + { "range": ">=0.156.0", "status": "supported" }, + { "range": ">=0.149.0 <0.156.0", "status": "unsupported" }, + { "range": "<0.149.0", "status": "broken" } + ] + }, + { + "driver": "claudeAgent", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2.1.280", + "ranges": [ + { "range": ">=2.1.280", "status": "supported" }, + { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, + { "range": "<2.1.111", "status": "unsupported" } + ] + }, + { + "driver": "openrouter", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2.1.280", + "ranges": [ + { "range": ">=2.1.280", "status": "supported" }, + { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, + { "range": "<2.1.111", "status": "unsupported" } + ] + }, + { + "driver": "cursor", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=2026.05.09", + "ranges": [ + { "range": ">=2026.05.09", "status": "supported" }, + { "range": "<2026.05.09", "status": "unknown" } + ] + }, + { + "driver": "grok", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.0.13", + "ranges": [ + { "range": ">=1.0.13", "status": "supported" }, + { "range": "<1.0.13", "status": "unknown" } + ] + }, + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.14.19 <2.0.0", + "recommendedVersion": "1.14.19", + "ranges": [ + { "range": ">=2.0.0", "status": "broken" }, + { "range": ">=1.14.19 <2.0.0", "status": "supported" }, + { "range": "<1.14.19", "status": "broken" } + ] + }, + { + "driver": "antigravity", + "t3CodeRange": ">=0.0.42", + "recommendedRange": "=1.1.1", + "ranges": [{ "range": "=1.1.1", "status": "supported" }] + } + ], "currentModels": { "codex": [ "gpt-6-astra", - "gpt-5.6-luna", - "gpt-5.6-terra", - "gpt-5.6-sol", + "gpt-6.1-sol", + "gpt-6-luna", "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-sonnet-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { @@ -605,6 +681,15 @@ "profile": "opus-5-5", "adapter": { "claudeCode": { "minVersion": "2.1.280" } } }, + { + "slug": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "aliases": ["sonnet-5.5", "claude-sonnet-5.5"], + "status": "current", + "badge": "new", + "profile": "sonnet-5", + "adapter": { "claudeCode": { "minVersion": "2.1.284" } } + }, { "slug": "claude-fable-5-1", "name": "Claude Fable 5.1", diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index e87f758e0e3d..d8319bc44a71 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -78,7 +78,6 @@ export function resolveOpenCodeServerPassword( : input.environment.OPENCODE_SERVER_PASSWORD; } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; @@ -289,11 +288,8 @@ export interface OpenCodeRuntimeShape { function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { - continue; - } - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - return match?.[1] ?? null; + const match = line.match(/server listening on\s+(https?:\/\/[^\s]+)/i); + if (match?.[1]) return match[1]; } return null; } diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 000000000000..3a43356d2caf --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,321 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import { BUILT_IN_DRIVERS } from "./builtInDrivers.ts"; +import * as Schema from "effect/Schema"; +import { + applyProviderCompatibility, + ProviderCompatibilityPolicy, + resolveProviderCompatibility, +} from "./providerCompatibility.ts"; + +const driver = ProviderDriverKind.make("codex"); +const policy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedVersion: "2.0.0", + recommendedRange: ">=2.0.0 <3.0.0", + ranges: [ + { range: "<1.0.0", status: "broken" }, + { range: ">=1.0.0 <1.5.0", status: "unsupported" }, + { range: ">=1.5.0 <2.0.0", status: "graceful" }, + { range: ">=2.0.0 <3.0.0", status: "supported" }, + ], +}; +const provider: ServerProvider = { + driver, + instanceId: ProviderInstanceId.make("codex-work"), + enabled: true, + installed: true, + version: "0.9.0", + status: "error", + message: "Authentication failed", + checkedAt: "2026-09-22T00:00:00Z", + auth: { status: "unauthenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +describe("provider compatibility", () => { + it("bundles a compatibility policy for every built-in harness", () => { + for (const builtIn of BUILT_IN_DRIVERS) { + assert.isDefined( + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + builtIn.driverKind, + null, + ), + `Missing bundled compatibility policy for ${builtIn.driverKind}`, + ); + } + }); + + it("compares Cursor build dates without treating semver prereleases as stable", () => { + const cursor = ProviderDriverKind.make("cursor"); + const cursorPolicy: ProviderCompatibilityPolicy = { + driver: cursor, + t3CodeRange: policy.t3CodeRange, + ranges: [ + { range: "<2026.05.09", status: "unsupported" }, + { range: ">=2026.05.09", status: "supported" }, + ], + }; + for (const [version, expected] of [ + ["2026.05.08-a1b2c3d", "unsupported"], + ["2026.05.09-a1b2c3d", "supported"], + ["2026.09.22-f2b0fcd", "supported"], + ["2026.05.09", "supported"], + ["2026.05.09-beta.1", "unknown"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility([cursorPolicy], cursor, version)?.status, + expected, + ); + } + assert.strictEqual( + resolveProviderCompatibility([policy], driver, "2.0.0-a1b2c3d")?.status, + "unknown", + ); + }); + + it("recognizes Antigravity semver release tags while keeping dated candidates unknown", () => { + const antigravity = ProviderDriverKind.make("antigravity"); + const taggedPolicy = { + ...policy, + driver: antigravity, + ranges: [{ range: "=2.0.0", status: "supported" as const }], + }; + for (const [version, expected] of [ + ["agy_acp_server_2.0.0", "supported"], + ["2.0.0", "supported"], + ["agy_acp_server_2.0.1", "unknown"], + ["agy_acp_server_2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility([taggedPolicy], antigravity, version)?.status, + expected, + ); + } + }); + + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { + for (const [version, expected] of [ + ["0.9.9", "broken"], + ["1.0.0", "unsupported"], + ["1.5.0", "graceful"], + ["2.0.0", "supported"], + ["v2.0.0", "supported"], + ["3.0.0", "unknown"], + ["2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + [null, "unknown"], + ] as const) { + assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); + } + assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); + }); + + it("supports every driver without inventing policies for uncovered adapters", () => { + for (const kind of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "customDriver", + ]) { + const adapter = ProviderDriverKind.make(kind); + assert.strictEqual( + resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, + "supported", + ); + assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); + } + }); + + it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { + const broken = applyProviderCompatibility(provider, [], [policy]); + assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); + const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; + const supported = applyProviderCompatibility(broken, [relaxed], [policy]); + assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(supported.status, "error"); + assert.strictEqual(supported.message, "Authentication failed"); + assert.strictEqual( + applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) + .compatibilityAdvisory?.status, + "broken", + ); + const removed = applyProviderCompatibility(supported, [], []); + assert.isUndefined(removed.compatibilityAdvisory); + assert.strictEqual(removed.status, "error"); + assert.strictEqual(removed.message, "Authentication failed"); + assert.isUndefined( + applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, + ); + assert.isUndefined( + applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) + .compatibilityAdvisory, + ); + }); + + it("rejects invalid ranges and recommendations outside the first supported match", () => { + const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); + assert.doesNotThrow(() => decode(policy)); + const prefixed = decode({ + ...policy, + t3CodeRange: ">=v0.0.42 =v2.0 =2.0.0 garbage", status: "supported" }] }, + { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, + { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, + ]) + assert.throws(() => decode(invalid)); + }); +}); + +it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const health = yield* Ref.make({ + ...provider, + status: "ready" as ServerProvider["status"], + message: "Healthy", + }); + const manifest = yield* Ref.make({ + version: 1, + currentModels: {}, + compatibility: [policy], + }); + const instance: ProviderInstance = { + instanceId: provider.instanceId, + driverKind: driver, + enabled: true, + displayName: undefined, + continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, + snapshot: { + getSnapshot: Ref.get(health), + refresh: Ref.get(health), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), + ), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const refresh = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Ref.updateAndGet(manifest, (current) => ({ + ...current, + compatibility: [ + { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, + ], + })), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(ModelManifest.ModelManifest, { + current: Ref.get(manifest), + refresh, + forceRefresh: refresh, + refreshInBackground: Effect.void, + }), + Layer.succeed(ProviderInstanceRegistry, { + getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* Deferred.await(started); + assert.strictEqual( + (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, + "broken", + ); + yield* Ref.set(health, provider); + const latestHealth = yield* registry.refreshInstance(provider.instanceId); + assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); + const supported = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), + ), + ); + const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + const updated = (yield* Fiber.join(subscribed))[0]?.[0]; + assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(updated?.status, "error"); + assert.strictEqual(updated?.message, "Authentication failed"); + }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.scoped), +); + +it("recomputes latest-version compatibility without losing the underlying update advisory", () => { + const snapshot: ServerProvider = { + ...provider, + version: "2.0.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.0", + latestVersion: "4.0.0", + canUpdate: true, + updateCommand: "npm install -g fixture@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }; + const blockedPolicy: ProviderCompatibilityPolicy = { + ...policy, + ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], + }; + const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); + assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); + const relaxed = applyProviderCompatibility( + blocked, + [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], + [], + ); + assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 000000000000..b4ce66f87603 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,127 @@ +import { + TrimmedNonEmptyString, + ServerProviderCompatibilityStatus, + type ProviderDriverKind, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; +import packageJson from "../../package.json" with { type: "json" }; + +// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. +// Prereleases and unrecognized release tags remain unknown. +const StableVersion = TrimmedNonEmptyString.pipe( + Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), +); +const VersionRange = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((value) => + value.split("||").every((group) => { + const tokens = group.trim().split(/\s+/); + return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); + }), + ), + ), +); +const Policy = Schema.Struct({ + driver: TrimmedNonEmptyString, + t3CodeRange: VersionRange, + recommendedRange: Schema.optionalKey(VersionRange), + recommendedVersion: Schema.optionalKey(StableVersion), + ranges: Schema.Array( + Schema.Struct({ + range: VersionRange, + status: ServerProviderCompatibilityStatus, + }), + ), +}); + +export const ProviderCompatibilityPolicy = Policy.pipe( + Schema.check( + Schema.makeFilter( + (policy) => { + const version = policy.recommendedVersion; + if (version === undefined) return true; + return ( + (policy.recommendedRange === undefined || + satisfiesSemverRange(version, policy.recommendedRange)) && + policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === + "supported" + ); + }, + { expected: "a recommended version in a supported range" }, + ), + ), +); +export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; + +export function resolveProviderCompatibility( + policies: ReadonlyArray | undefined, + driver: ProviderDriverKind, + version: string | null, + t3CodeVersion = packageJson.version, +): ServerProviderCompatibilityAdvisory | undefined { + const policy = policies?.find( + (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), + ); + 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. + // Strip only these driver-specific forms, keeping semver prereleases unknown. + const stable = + driver === "cursor" + ? unprefixed?.replace(/^(\d{4}\.\d{2}\.\d{2})-[a-f0-9]+$/, "$1") + : driver === "antigravity" + ? unprefixed?.replace(/^agy_acp_server_(\d+\.\d+\.\d+)$/, "$1") + : unprefixed; + const status = + stable && /^\d+\.\d+\.\d+$/.test(stable) + ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? + "unknown") + : "unknown"; + const message = + status === "broken" + ? "This provider version is known to be incompatible with this T3 Code release." + : status === "unsupported" + ? "This provider version is outside the supported range for this T3 Code release." + : status === "graceful" + ? "This provider version has limited compatibility with this T3 Code release." + : null; + const recommendedVersion = policy.recommendedVersion ?? null; + const recommendedRange = policy.recommendedRange ?? null; + const recommendation = recommendedVersion ?? recommendedRange; + return { + status, + message: message && recommendation ? `${message} Use ${recommendation}.` : message, + recommendedVersion, + recommendedRange, + }; +} + +/** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ +export function applyProviderCompatibility( + snapshot: ServerProvider, + policies: ReadonlyArray | undefined, + fallback: ReadonlyArray | undefined, +): ServerProvider { + const { compatibilityAdvisory: _previous, ...base } = snapshot; + if (!snapshot.enabled || !snapshot.installed) return base; + const advisory = + resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? + resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); + const latestVersion = snapshot.versionAdvisory?.latestVersion; + const latestAdvisory = latestVersion + ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? + resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) + : undefined; + return advisory + ? { + ...base, + compatibilityAdvisory: { + ...advisory, + ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), + }, + } + : base; +} diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index dba0bafecad7..b1bea19c2df1 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { layerTest as settingsLayerTest } from "../serverSettings.ts"; +import { CodexInstallation } from "./CodexInstallation.ts"; import { AntigravityInstallation } from "./AntigravityInstallation.ts"; import type { ProviderInstance } from "./ProviderDriver.ts"; import { makeProviderInstallation } from "./providerInstallation.ts"; @@ -33,9 +34,9 @@ const state: ProviderInstallState = { message: null, }; -function instance(kind = driver): ProviderInstance { +function instance(kind = driver, id = instanceId): ProviderInstance { return { - instanceId, + instanceId: id, driverKind: kind, enabled: false, displayName: undefined, @@ -77,6 +78,25 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* return []; }), }), + Layer.mock(CodexInstallation)({ + managedDirectory: "/unused-managed-codex", + start: Effect.sync(() => { + calls.push("codex-start"); + return { ...state, driver: ProviderDriverKind.make("codex") }; + }), + cancel: () => + Effect.sync(() => { + calls.push("codex-cancel"); + return { ...state, driver: ProviderDriverKind.make("codex") }; + }), + state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }), + remove: (paths) => + Effect.sync(() => { + protectedPaths = paths ?? []; + calls.push("codex-remove"); + }), + }), Layer.mock(AntigravityInstallation)({ managedDirectory: "/unused-managed-runtime", start: Effect.sync(() => { @@ -139,6 +159,37 @@ describe("provider installation routing", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("routes a managed Codex instance through its own installer and refreshes removal", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex"); + const harness = yield* makeHarness({ + instance: instance(ProviderDriverKind.make("codex"), codexId), + settings: { providers: { codex: { setupMode: "managed" } } }, + }); + assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); + yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); + const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId: codexId })); + assert.equal(Array.from(observed)[0]?.driver, "codex"); + yield* harness.router.remove({ instanceId: codexId }); + assert.deepEqual(harness.calls, ["codex-start", "codex-cancel", "codex-remove", "refresh"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps native Codex installation outside managed setup", () => + Effect.gen(function* () { + const codexId = ProviderInstanceId.make("codex"); + const harness = yield* makeHarness({ + instance: instance(ProviderDriverKind.make("codex"), codexId), + settings: { providers: { codex: { setupMode: "existing" } } }, + }); + assert.include( + (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, + "Choose managed setup", + ); + assert.deepEqual(harness.calls, []); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("protects another instance's binary found through its own PATH", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index cdec50fb0bb2..5cf99ce27b95 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -1,5 +1,6 @@ import { AntigravitySettings, + CodexSettings, ProviderDriverKind, type ProviderInstallCancelInput, type ProviderInstanceId, @@ -11,6 +12,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { CodexInstallation, type CodexInstallationError } from "./CodexInstallation.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import { AntigravityInstallation, @@ -23,11 +25,13 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment. const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); /** Route instance setup to the environment-owned installer without owning the download. */ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () { - const installation = yield* AntigravityInstallation; + const antigravityInstallation = yield* AntigravityInstallation; + const codexInstallation = yield* CodexInstallation; const instances = yield* ProviderInstanceRegistry; const providers = yield* ProviderRegistry; const settings = yield* ServerSettingsService; @@ -55,26 +59,37 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu managedOnly = false, ) { const instance = yield* instances.getInstance(instanceId); - if (instance?.driverKind !== ANTIGRAVITY) { + const isCodex = instance?.driverKind === ProviderDriverKind.make("codex"); + if (instance?.driverKind !== ANTIGRAVITY && !isCodex) { return yield* new ProviderSetupError({ instanceId, operation, detail: "Managed installation is not available for this provider instance.", }); } - if (!managedOnly) return; + const installation = isCodex ? codexInstallation : antigravityInstallation; const entries = yield* readEntries(instanceId, operation); - const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation, - detail: "The Antigravity instance configuration is invalid.", - }), - ), - ); - if (config.binaryPath) { + const invalidConfig = () => + new ProviderSetupError({ + instanceId, + operation, + detail: "The provider instance configuration is invalid.", + }); + const config = isCodex + ? yield* decodeCodexSettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError(invalidConfig), + ) + : yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe( + Effect.mapError(invalidConfig), + ); + if (isCodex && (!("setupMode" in config) || config.setupMode !== "managed")) { + return yield* new ProviderSetupError({ + instanceId, + operation, + detail: "Choose managed setup to install Codex in T3 Code.", + }); + } + if (managedOnly && config.binaryPath && (!isCodex || config.binaryPath !== "codex")) { return yield* new ProviderSetupError({ instanceId, operation, @@ -82,20 +97,23 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.", }); } + return { installation, driver: instance.driverKind }; }); - const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) => - new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); + const failure = + (instanceId: ProviderInstanceId) => + (error: AntigravityInstallationError | CodexInstallationError) => + new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail }); const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) { - yield* requireInstance(input.instanceId, "install", true); + const { installation } = yield* requireInstance(input.instanceId, "install", true); return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId))); }); const cancel = Effect.fn("ProviderInstallation.cancel")(function* ( input: ProviderInstallCancelInput, ) { - yield* requireInstance(input.instanceId, "cancel-install"); + const { installation } = yield* requireInstance(input.instanceId, "cancel-install"); return yield* installation .cancel(input.operationId) .pipe(Effect.mapError(failure(input.instanceId))); @@ -103,11 +121,17 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu const subscribe = (input: ProviderSetupInput) => Stream.unwrap( - requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)), + requireInstance(input.instanceId, "observe-install").pipe( + Effect.map(({ installation }) => installation.changes), + ), ); const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) { - yield* requireInstance(input.instanceId, "remove-install", true); + const { installation, driver } = yield* requireInstance( + input.instanceId, + "remove-install", + true, + ); const entries = yield* readEntries(input.instanceId, "remove-install"); const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => { if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) { @@ -118,7 +142,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu env: mergeProviderInstanceEnvironment(entry.environment), }).pipe( Effect.map((resolved) => [binaryPath, resolved]), - Effect.catch(() => Effect.succeed([binaryPath])), + Effect.orElseSucceed(() => [binaryPath]), ); }); yield* installation @@ -126,7 +150,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu .pipe(Effect.mapError(failure(input.instanceId))); const allInstances = yield* instances.listInstances; yield* Effect.forEach( - allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY), + allInstances.filter((instance) => instance.driverKind === driver), (instance) => providers.refreshInstance(instance.instanceId), { discard: true }, ); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 3e0810f51b7a..50aee2b08181 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, + makeTargetedProviderUpdateAction, enrichProviderSnapshotWithVersionAdvisory, homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); }); + +it("pins only owned package-manager installs and preserves their execution context", () => { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver("codex"), + packageName: "@openai/codex", + updateExecutable: "npm", + updateLockKey: "npm-global:/opt/node", + updateArgs: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + env: { PATH: "/opt/node/bin" }, + }); + const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); + expect(pinned).toMatchObject({ + executable: "npm", + lockKey: capabilities.update?.lockKey, + env: capabilities.update?.env, + args: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@2.0.0", + ], + }); + expect(pinned?.command).toContain("@openai/codex@2.0.0"); + for (const lockKey of ["codex-native", "homebrew", "manual"]) { + expect( + makeTargetedProviderUpdateAction( + { ...capabilities, update: { ...capabilities.update!, lockKey } }, + "2.0.0", + ), + ).toBeNull(); + } + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a11b22462ee..ace445b03060 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + version: string, +): ProviderMaintenanceCommandAction | null { + if (!/^\d+\.\d+\.\d+$/.test(version)) return null; + const update = capabilities.update; + const packageName = capabilities.packageName; + if (!update || !packageName) return null; + if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + return null; + const packageIndex = update.args.findIndex( + (arg) => arg === `${packageName}@latest` || arg === packageName, + ); + if (packageIndex < 0) return null; + const args = update.args.map((arg, index) => + index === packageIndex ? `${packageName}@${version}` : arg, + ); + const previous = update.args[packageIndex]!; + const commandIndex = update.command.lastIndexOf(previous); + const command = + commandIndex < 0 + ? update.command + : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; + return { ...update, args, command }; +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, + canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..194dd4fb4ba1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -208,13 +209,32 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = ( + registry: ProviderRegistryShape, + // Generic updater fixtures use synthetic versions. Keep their compatibility + // unknown so real harness minimums do not bypass the command under test. + manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [CODEX_DRIVER, CURSOR_DRIVER, OPENCODE_DRIVER].map((driver) => ({ + driver, + t3CodeRange: ">=0.0.42", + ranges: [], + })), + }, +) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Effect.succeed(manifest), + refreshInBackground: Effect.void, + }), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), ), @@ -743,7 +763,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), + latestVersionHttpClient("2.0.0"), mockSpawnerLayer((_command, args) => { calls.push(args.join(" ")); if (calls.length === 1) { @@ -911,3 +931,92 @@ describe("providerMaintenanceRunner", () => { ); }); }); + +it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { + const calls: string[] = []; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [ + { range: "=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(); + const pinnedCapabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global:/fixture", + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + }, + manifest, + ); + for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + ...(targetVersion !== undefined ? { targetVersion } : {}), + }); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + } + assert.deepStrictEqual(calls, []); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "2.0.0" })), + ); + const installed = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); + assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "1.0.0" })), + ); + const unchanged = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + const nativeUpdater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...pinnedCapabilities, + update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, + }), + }, + manifest, + ); + const refused = yield* nativeUpdater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.strictEqual(calls.length, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("3.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 05566a438a5f..79af02d60c69 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ModelManifest from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, + resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -214,6 +219,7 @@ function makeUpdateState(input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; + const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const manifest = yield* manifestService.current; + const candidateVersion = + targetVersion ?? + (yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + )); + const advisory = + resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + provider, + candidateVersion, + ); + const command = + targetVersion !== undefined + ? makeTargetedProviderUpdateAction(fresh, targetVersion) + : fresh.update; + const rejected = + targetVersion !== undefined + ? !command || + advisory?.recommendedVersion !== targetVersion || + advisory.status !== "supported" + : advisory?.status === "broken" || advisory?.status === "unsupported"; + if (rejected || !command) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: + targetVersion !== undefined + ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." + : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", + }), + ); + } + const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); - const stillOutdated = verifiedProviders.some((verifiedProvider) => - isOutdatedProvider(verifiedProvider), - ); + verifiedProviders.some( + (verifiedProvider) => + !isStillInstalled(verifiedProvider) || + (targetVersion !== undefined && + verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + ); + const stillOutdated = + targetVersion === undefined && + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 440f1558a684..5cc18af8b077 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -84,7 +84,18 @@ rl.on("line", (line) => { return; } if (method === "account/read") { - write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + write({ + id, + result: { account: script.account ?? { type: "apiKey" }, requiresOpenaiAuth: false }, + }); + return; + } + if (method === "account/rateLimits/read" && script.failRateLimitsRead) { + write({ id, error: { code: -32000, message: "usage unavailable" } }); + return; + } + if (method === "account/rateLimitResetCredit/consume" && script.resetCreditOutcome) { + write({ id, result: { outcome: script.resetCreditOutcome } }); return; } if (method === "skills/list" || method === "model/list") { @@ -95,6 +106,14 @@ rl.on("line", (line) => { write({ id, result: fixture.responses.threadStart }); return; } + if (method === "thread/inject_items" && script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + write({ id, result: {} }); + return; + } if (method === "thread/resume") { if (script.recordRequests) { NodeFS.appendFileSync( diff --git a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json index 08316d3b6334..0f183635b17e 100644 --- a/apps/server/src/provider/testFixtures/codexMultiAgentWire.json +++ b/apps/server/src/provider/testFixtures/codexMultiAgentWire.json @@ -20,6 +20,7 @@ "forkedFromId": null, "parentThreadId": null, "preview": "", + "projectId": null, "ephemeral": false, "historyMode": "legacy", "modelProvider": "openai", @@ -389,6 +390,7 @@ "forkedFromId": null, "parentThreadId": null, "preview": "", + "projectId": null, "ephemeral": false, "historyMode": "legacy", "modelProvider": "openai", diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index fd5ab04e8a81..291105e25cd9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( output( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 723fcfc4d9b9..a609fd3d097b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,10 +1,15 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -48,6 +53,7 @@ import { decodePullRequestCoreJson, PULL_REQUEST_CORE_GRAPHQL_QUERY, type GitHubPullRequestCore, + type GitHubPullRequestSummary, decodePullRequestPreviewJson, PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decodePullRequestFilesJson, @@ -58,6 +64,7 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, decodeReactionSubjectScopeJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, @@ -67,6 +74,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -419,6 +427,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1570,31 +1595,30 @@ export const make = Effect.gen(function* () { decode: decodePullRequestCoreJson, }), ), - ).pipe( - Effect.flatMap((core) => { - if (!core.checksTruncated) return Effect.succeed(core); - // gh already pages check contexts. Keep its complete, deduplicated result for - // large check suites instead of letting the first 100 checks imply success. - return readLegacyDetail(input).pipe( - Effect.flatMap((detail) => - detail.headSha !== core.headSha - ? Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDetail", - cause: new Error("Pull request head changed while reading checks."), - }), - ) - : Effect.succeed({ - ...core, - checks: detail.checks, - checksState: detail.checksState, - checksTruncated: false, + // gh already pages check contexts. Keep its complete, deduplicated result for + // large check suites instead of letting the first 100 checks imply success. + Effect.filterOrElse( + (core) => !core.checksTruncated, + (core) => + readLegacyDetail(input).pipe( + Effect.filterOrFail( + (detail) => detail.headSha === core.headSha, + () => + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: new Error("Pull request head changed while reading checks."), }), + ), + Effect.map((detail) => ({ + ...core, + checks: detail.checks, + checksState: detail.checksState, + checksTruncated: false, + })), ), - ); - }), + ), ); }; @@ -1727,6 +1751,133 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ withVerifiedCredential, getRoutingIdentity, @@ -1815,10 +1966,9 @@ export const make = Effect.gen(function* () { // the fallback out: an empty answer under one is already the answer. const hasQuery = (input.query?.trim().length ?? 0) > 0; return read(true).pipe( - Effect.flatMap((batch) => - batch.items.length === 0 && input.cursor === undefined && !hasQuery - ? read(false) - : Effect.succeed(batch), + Effect.filterOrElse( + (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, + () => read(false), ), Effect.flatMap((batch) => { // Match the search query's host support, and enrich only rows that survived paging. @@ -1933,56 +2083,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, getPullRequestPreview: (input) => { @@ -2028,6 +2129,7 @@ export const make = Effect.gen(function* () { }), ); }), + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. Effect.flatMap((stack) => { if (!input.includeDetails || stack === null) return Effect.succeed(stack); return github diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 5a07bb1ae37c..c3b607002497 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1250,7 +1250,7 @@ export const make = Effect.gen(function* () { getMergeRequestDiffFileContents: (input) => Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); } const refs = yield* input.commit === undefined ? getDiffRefs(input) diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 1b4ded39b953..724fbe490d7f 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -77,7 +77,7 @@ export const make = Effect.gen(function* () { () => backing .get("revisions") - .pipe(Effect.flatMap((raw) => Schema.decodeUnknownEffect(revisionCodec)(raw ?? "{}"))), + .pipe(Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}"))), { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a0d111bd710f..4ba438a97da9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3251,13 +3251,13 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = ], }); - yield* Effect.all( - ["all", "authored", "reviewing"].map((involvement) => + yield* Effect.forEach( + ["all", "authored", "reviewing"], + (involvement) => service.list({ state: "open", involvement: involvement as "all" | "authored" | "reviewing", }), - ), { concurrency: "unbounded" }, ); @@ -4328,6 +4328,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => ], }); const readOperation = (input: Parameters[0]) => + // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through. Effect.gen(function* () { yield* service[operation](input); }); @@ -4391,6 +4392,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco ], }); const readOperation = (input: Parameters[0]) => + // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through. Effect.gen(function* () { yield* service[operation](input); }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 6b4b2bd8bfd5..866a45e49b09 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2751,7 +2751,7 @@ export const make = Effect.gen(function* () { `project:${input.projectId}`, refScope(input), ]); - const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); + const decoded = yield* Schema.decodeEffect(codec)(payload).pipe(Effect.option); return Option.isSome(decoded) ? decoded.value : yield* lookup; }); const summaryCodec = Schema.fromJsonString(PullRequestSummary); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 81033e2e0d94..7008ca739150 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -1883,3 +1885,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index eaaa4e34da84..9be1b2a5844b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1847,6 +1847,129 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 8d73909cc696..a818124bb86f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -20,6 +20,7 @@ import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -27,6 +28,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -325,6 +327,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const projectId = "project-1" as ProjectId; const activeThreadId = "thread-active" as ThreadId; const idleThreadId = "thread-idle" as ThreadId; + const oldCompletedId = "thread-old-completed" as ThreadId; + const newCompletedId = "thread-new-completed" as ThreadId; + const freshMessageId = "thread-fresh-message" as ThreadId; const baseThread = { projectId, @@ -351,6 +356,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect( AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt: Date.parse(now), projects: [ { id: projectId, @@ -374,6 +380,44 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ...baseThread, id: idleThreadId, }, + { + ...baseThread, + id: oldCompletedId, + latestTurn: { + turnId: "turn-old" as TurnId, + state: "completed", + requestedAt: "2026-05-24T00:00:00.000Z", + startedAt: "2026-05-24T00:00:00.000Z", + completedAt: "2026-05-24T00:01:00.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: newCompletedId, + latestTurn: { + turnId: "turn-new" as TurnId, + state: "completed", + requestedAt: "2026-05-25T00:00:01.000Z", + startedAt: "2026-05-25T00:00:01.000Z", + completedAt: "2026-05-25T00:00:02.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: freshMessageId, + latestUserMessageAt: "2026-05-25T00:00:01.000Z", + session: { + threadId: freshMessageId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-05-25T00:00:02.000Z", + }, + }, { ...baseThread, id: "thread-missing-project" as ThreadId, @@ -389,7 +433,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, ], }), - ).toEqual([activeThreadId]); + ).toEqual([activeThreadId, newCompletedId]); }); it.effect("signs the activity publish JWT and rejects tampering", () => @@ -533,7 +577,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Deferred.await(releaseThreadShell); return Option.some(thread); }), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getProjectShellById: () => Effect.succeedSome(project), } as unknown as ProjectionSnapshotQueryShape; const descriptor = { @@ -750,8 +794,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { threads: [thread], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => Effect.succeed(Option.some(thread)), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getThreadShellById: () => Effect.succeedSome(thread), + getProjectShellById: () => Effect.succeedSome(project), } as unknown as ProjectionSnapshotQueryShape), ); @@ -797,4 +841,249 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ), ); + + it.effect("does not alert for historical completions after startup", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.add(now, { days: -7 })); + const threadId = "thread-old" as ThreadId; + const projectId = "project-1" as ProjectId; + const environmentId = "env-1" as EnvironmentId; + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: old, + updatedAt: old, + } satisfies OrchestrationProjectShell; + const completedTurn = { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: old, + startedAt: old, + completedAt: old, + assistantMessageId: null, + } as const; + const completedThread = { + id: threadId, + projectId, + title: "Old task", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: completedTurn, + createdAt: old, + updatedAt: old, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: old, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + let currentThread: OrchestrationThreadShell | null = completedThread; + let publishes = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + publishes += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, {} as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getThreadShellById: () => Effect.sync(() => Option.fromNullishOr(currentThread)), + getProjectShellById: () => Effect.succeedSome(project), + } as unknown as ProjectionSnapshotQueryShape), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + latestTurn: null, + latestUserMessageAt: DateTime.formatIso(DateTime.add(now, { seconds: 1 })), + session: { + threadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: DateTime.formatIso(DateTime.add(now, { seconds: 2 })), + }, + }; + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some(currentThread), + project: Option.some(project), + }).state?.phase, + ).toBe("completed"); + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + session: { + threadId, + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "old failure", + updatedAt: old, + }, + }; + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); +}); + +describe.sequential("startup catch-up", () => { + // An unlinked relay with publishing off. `link` writes the link secrets and + // `enablePublishing` the opt-in. Counts link checks (relay URL reads) and + // catch-up publishes (shell snapshot reads). + function makeUnlinkedRelay() { + const secrets = makeMemorySecretStore(); + const counts = { linkChecks: 0, catchUpPublishes: 0 }; + const countingStore = { + ...secrets.store, + get: (name: string) => + Effect.suspend(() => { + if (name === RELAY_URL_SECRET) counts.linkChecks += 1; + return secrets.store.get(name); + }), + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const layer = AgentAwarenessRelay.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, countingStore), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed("env-1" as EnvironmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, { + streamDomainEvents: Stream.never, + } as unknown as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.sync(() => { + counts.catchUpPublishes += 1; + return { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-05-25T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + }), + } as unknown as ProjectionSnapshotQueryShape), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + const link = Effect.all( + [ + secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"), + secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"), + ], + { discard: true }, + ); + const enablePublishing = secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + return { counts, layer, link, enablePublishing }; + } + + it.effect("checks an unlinked environment once a minute and still catches up once linked", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Get past the backoff ramp, then count checks in a steady window. + yield* TestClock.adjust("10 minutes"); + const checksBeforeWindow = counts.linkChecks; + yield* TestClock.adjust("10 minutes"); + expect(counts.linkChecks - checksBeforeWindow).toBe(10); + expect(counts.catchUpPublishes).toBe(0); + + yield* link; + yield* TestClock.adjust("1 minute"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("publishes at once when this process links while the check is backed off", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Backed off to 60 s: the next check is still seconds away. + yield* TestClock.adjust("10 minutes"); + yield* link; + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(0); + + yield* relay.requestCatchUp(); + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("catches up within 5 s when another process enables publishing on a link", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* link; + yield* relay.start(); + + yield* TestClock.adjust("10 minutes"); + expect(counts.catchUpPublishes).toBe(0); + + // `t3 connect publish` writes the opt-in without waking this process. + yield* enablePublishing; + yield* TestClock.adjust("5 seconds"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 052a67959ad1..c9acf6b510fc 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,6 +52,8 @@ export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, { readonly publishThread: (threadId: ThreadId) => Effect.Effect; + /** Retries a pending catch-up publish now. Call after this process links or enables publishing. */ + readonly requestCatchUp: () => Effect.Effect; readonly start: () => Effect.Effect; } >()("t3/relay/AgentAwarenessRelay") {} @@ -267,8 +270,13 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { }; } +function terminalWorkSinceStart(thread: OrchestrationThreadShell, startedAt: number): boolean { + return Date.parse(thread.latestTurn?.completedAt ?? "") > startedAt; +} + export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; + readonly startedAt: number; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; }): ReadonlyArray { @@ -279,12 +287,16 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { if (!project) { return false; } + const state = projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }); return ( - projectThreadAwareness({ - environmentId: input.environmentId, - project, - thread, - }) !== null + state !== null && + (state.phase !== "completed" && state.phase !== "failed" + ? true + : terminalWorkSinceStart(thread, input.startedAt)) ); }) .map((thread) => thread.id); @@ -298,7 +310,10 @@ export const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const crypto = yield* Crypto.Crypto; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); + // Holds at most one pending wake, so a burst of requests costs one retry. + const catchUpRequests = yield* Queue.dropping(1); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => @@ -417,6 +432,14 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + if ( + (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") && + !publishedStateByThread.has(threadId) + ) { + // Startup has no publish history. Only work from this server process may + // produce an initial terminal alert; historical threads remain quiet. + 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 @@ -495,7 +518,11 @@ export const make = Effect.gen(function* () { }); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); - nextPublishedStates.set(threadId, publishIdentity); + if (snapshot.state === null) { + nextPublishedStates.delete(threadId); + } else { + nextPublishedStates.set(threadId, publishIdentity); + } return nextPublishedStates; }); }); @@ -512,42 +539,63 @@ export const make = Effect.gen(function* () { withRelayClientTracing, ); + // Publishes the active threads once. Returns why it did not, so the retry + // knows whether it is waiting on a link or on the publish setting. const publishActiveThreadsUnsafe = Effect.gen(function* () { + // One secret read settles the common never-linked case; the full link + // config is read only once publishing is on. + const relayUrl = yield* readSecretString(RELAY_URL_SECRET).pipe( + Effect.orElseSucceed(() => null), + ); + if (!relayUrl) { + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); + return "unlinked" as const; + } const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); - return false; + return "disabled" as const; } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); - return false; + return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; const snapshot = yield* snapshotQuery.getShellSnapshot(); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt, projects: snapshot.projects, threads: snapshot.threads, }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); - return true; + return "published" as const; } yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); - return true; + return "published" as const; }); + // Publishes the catch-up snapshot of active threads once the environment is + // linked and publishing is enabled. Many environments never link, so while + // unlinked the retry backs off from 5 s to 60 s. Only this process writes + // the link, and it calls `requestCatchUp`, which ends the wait early. A + // linked environment keeps the 5 s retry, because `t3 connect publish` can + // turn publishing on from another process. const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => Effect.gen(function* () { + let unlinkedRetryDelayMs = 5_000; while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { yield* Ref.set(activeSnapshotPublishedRef, true); if (logEnabledWhenReady) { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); @@ -557,7 +605,11 @@ export const make = Effect.gen(function* () { } return; } - yield* Effect.sleep("5 seconds"); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } } }); @@ -634,6 +686,7 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, + requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), start, }); }); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 032832dd4869..0f61ac1899b6 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -60,7 +60,7 @@ export const make = Effect.fn("makeHostResources")(function* () { if (platform === "linux") { const meminfo = yield* fs .readFileString("/proc/meminfo") - .pipe(Effect.catch(() => Effect.succeed(""))); + .pipe(Effect.orElseSucceed(() => "")); const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; if (available) availableMemoryBytes = Number(available) * 1024; } else if (platform === "darwin") { @@ -68,7 +68,7 @@ export const make = Effect.fn("makeHostResources")(function* () { .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) .pipe( Effect.timeout("1 second"), - Effect.catch(() => Effect.succeed("")), + Effect.orElseSucceed(() => ""), ); availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 384aaf340067..5daed8b1b418 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -133,6 +133,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -140,10 +141,14 @@ import { AntigravityInstallation, AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; +import { CodexInstallation } from "./provider/CodexInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + ProviderVersionCache, +} from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; @@ -173,10 +178,12 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import { REPLAY_MARKER_MAX_AGE } from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; @@ -220,6 +227,7 @@ import { } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -485,7 +493,7 @@ const makeBrowserOtlpPayload = (spanName: string) => url: collector.url, exportInterval: "10 millis", resource: { - serviceName: "t3-web", + serviceName: "t3code-web", attributes: { "service.runtime": "t3-web", "service.mode": "browser", @@ -517,11 +525,13 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; providerInstanceRegistry?: Partial; antigravityInstallation?: Partial; + codexInstallation?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -556,7 +566,9 @@ const buildAppUnderTest = (options?: { CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"] >; relayClient?: Partial; + agentAwarenessRelay?: Partial; cloudCliTokenManager?: Partial; + httpClient?: HttpClient.HttpClient; nativeTelemetryClient?: Partial; desktopTelemetryReceiver?: Partial< DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] @@ -582,7 +594,7 @@ const buildAppUnderTest = (options?: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, host: "127.0.0.1", @@ -648,25 +660,25 @@ const buildAppUnderTest = (options?: { get: () => Effect.succeed(defaultVcsDriver), detect: (input) => defaultVcsDriver.detectRepository(input.cwd).pipe( - Effect.flatMap((repository) => - repository - ? Effect.succeed(repository) - : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( - Effect.map((isInsideWorkTree) => - isInsideWorkTree - ? { - kind: "git" as const, - rootPath: input.cwd, - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - } - : null, - ), + Effect.filterOrElse( + (repository) => repository !== null, + () => + defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( + Effect.map((isInsideWorkTree) => + isInsideWorkTree + ? { + kind: "git" as const, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + } + : null, ), + ), ), Effect.map((repository) => repository @@ -791,6 +803,10 @@ const buildAppUnderTest = (options?: { ), Layer.provide( Layer.mergeAll( + Layer.mock(ModelManifest.ModelManifest)({ + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + ...options?.layers?.modelManifest, + }), Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), refresh: () => Effect.succeed([]), @@ -811,17 +827,21 @@ const buildAppUnderTest = (options?: { ...options?.layers?.providerAuth, }), Layer.mock(ProviderInstanceRegistry)({ - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, listInstances: Effect.succeed([]), ...options?.layers?.providerInstanceRegistry, }), + Layer.mock(CodexInstallation)({ + managedDirectory: "unused-test-codex-runtime", + ...options?.layers?.codexInstallation, + }), Layer.mock(AntigravityInstallation)({ managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ upsert: () => Effect.void, - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), ...options?.layers?.providerSessionDirectory, @@ -848,7 +868,7 @@ const buildAppUnderTest = (options?: { Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), availableEditorsSnapshot: () => Effect.succeed([]), - resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), + resolveFileManagerRevealKind: () => Effect.undefined, ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -1024,20 +1044,20 @@ const buildAppUnderTest = (options?: { }), searchThreads: () => Effect.succeed({ matches: [] }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getProjectShellById: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => Effect.succeed({ eventCount: Math.max(0, toSequenceInclusive - fromSequenceExclusive), payloadBytes: 0, }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.succeed([]), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, ...options?.layers?.projectionSnapshotQuery, }), ), @@ -1163,11 +1183,20 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.succeed( - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => Effect.succeed({ status: "disabled" }), - ...options?.layers?.cloudManagedEndpointRuntime, + Layer.mergeAll( + Layer.succeed( + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ + requestCatchUp: () => Effect.void, + ...options?.layers?.agentAwarenessRelay, }), ), ), @@ -1188,7 +1217,7 @@ const buildAppUnderTest = (options?: { Layer.provide( Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")), - getExisting: Effect.succeed(Option.none()), + getExisting: Effect.succeedNone, hasCredential: Effect.succeed(false), clear: Effect.void, ...options?.layers?.cloudCliTokenManager, @@ -1215,7 +1244,11 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(makeAuthTestLayer()), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), - Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge( + options?.layers?.httpClient === undefined + ? FetchHttpClient.layer + : Layer.succeed(HttpClient.HttpClient, options.layers.httpClient), + ), Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), Layer.provide(layerConfig), ); @@ -2627,6 +2660,40 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects a DPoP replay by time alone once its marker can be pruned", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const acceptedAt = yield* DateTime.now; + // The longest-lived proof: `iat` at the 5 s future skew the verifier allows. + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(acceptedAt.epochMilliseconds / 1_000) + 5, + }); + const exchange = exchangeAccessToken(credential.credential, { + headers: { dpop: dpop.proof }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal((yield* exchange).response.status, 200); + // While the proof is fresh, only the replay marker rejects it. + assert.equal((yield* exchange).body.dpopFailureReason, "replay"); + // Once the marker can be pruned, the time check rejects the proof by itself. + yield* TestClock.setTime( + acceptedAt.epochMilliseconds + Duration.toMillis(REPLAY_MARKER_MAX_AGE), + ); + assert.equal((yield* exchange).body.dpopFailureReason, "time_window"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3063,6 +3130,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("wakes the agent awareness relay when this server links or changes publishing", () => + Effect.gen(function* () { + let catchUpRequests = 0; + yield* buildAppUnderTest({ + layers: { + agentAwarenessRelay: { + requestCatchUp: () => + Effect.sync(() => { + catchUpRequests += 1; + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + assert.equal(catchUpRequests, 1); + + const preferencesResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/preferences"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ publishAgentActivity: true }), + }, + ); + assert.equal(preferencesResponse.status, 200); + assert.equal(catchUpRequests, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects relay config with an invalid cloud mint public key", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3214,6 +3330,68 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects a non-Cloudflare managed endpoint runtime without persisting the link", () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "unsupported", providerKind: config.providerKind } as const); + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "manual", + connectorToken: "manual-token", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly endpointRuntimeStatus?: { readonly status?: string }; + }>(relayConfigResponse); + const linkStateUrl = yield* getHttpServerUrl("/api/connect/link-state"); + const linkStateResponse = yield* fetchEffect(linkStateUrl, { + headers: { cookie: ownerCookie }, + }); + const linkStateBody = yield* responseJsonEffect<{ readonly linked?: boolean }>( + linkStateResponse, + ); + + assert.equal(relayConfigResponse.status, 503); + assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); + assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "unsupported"); + // The connector is never touched for a rejected runtime. + assert.deepEqual(appliedRuntimeConfigs, []); + assert.equal(linkStateResponse.status, 200); + assert.equal(linkStateBody.linked, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("reports local cloud link state from persisted relay config", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -3292,6 +3470,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("unlinks local cloud state and disables the managed endpoint runtime", () => Effect.gen(function* () { const appliedRuntimeConfigs: Array = []; + const requestedRecoveryConfigs: Array = []; yield* buildAppUnderTest({ layers: { cloudManagedEndpointRuntime: { @@ -3308,7 +3487,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), }); }, + requestRecovery: (config) => + Effect.sync(() => { + requestedRecoveryConfigs.push(config); + }), }, + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ status: "ready" }))), + ), }, }); @@ -3374,6 +3560,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(linkStateBody.relayUrl, null); assert.equal(linkStateBody.relayIssuer, null); assert.deepEqual(appliedRuntimeConfigs, [ + null, { providerKind: "cloudflare_tunnel", connectorToken: "connector-token", @@ -3382,6 +3569,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, null, ]); + assert.deepEqual(requestedRecoveryConfigs, []); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -3622,6 +3810,82 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects cloud replays by time alone once their markers can be pruned", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + + const acceptedAt = yield* DateTime.now; + // The longest-lived proofs: `iat` at the 60 s future skew the handlers + // allow, and the 5 minute maximum lifetime. + const issuedAt = DateTime.add(acceptedAt, { minutes: 1 }); + const proofTimes = { + issuedAt: DateTime.formatIso(issuedAt), + expiresAt: DateTime.formatIso(DateTime.add(issuedAt, { minutes: 5 })), + }; + const requests = [ + [ + "/api/t3-connect/health", + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + nonce: "cloud-health-nonce-pruned", + ...proofTimes, + }), + ], + [ + "/api/t3-connect/mint-credential", + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + nonce: "cloud-mint-nonce-pruned", + ...proofTimes, + }), + ], + ] as const; + const postAll = Effect.forEach(requests, ([pathname, request]) => + Effect.gen(function* () { + const response = yield* fetchEffect(yield* getHttpServerUrl(pathname), { + method: "POST", + headers: { "content-type": "application/json" }, + body: jsonRequestBody(request), + }); + return response.status; + }), + ); + + assert.deepStrictEqual(yield* postAll, [200, 200]); + // While the proofs are fresh, only the replay markers reject them (409). + assert.deepStrictEqual(yield* postAll, [409, 409]); + // Once the markers can be pruned, the time checks reject the proofs by themselves (401). + yield* TestClock.setTime( + acceptedAt.epochMilliseconds + Duration.toMillis(REPLAY_MARKER_MAX_AGE), + ); + assert.deepStrictEqual(yield* postAll, [401, 401]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "validates cloud proofs against the configured relay issuer, not the transport URL", () => @@ -3692,19 +3956,169 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps a managed connector stopped when relay registration fails", () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + const relayRequests: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "running", providerKind: "cloudflare_tunnel", pid: 123 } as const); + }), + }, + httpClient: HttpClient.make((request) => + Effect.sync(() => { + relayRequests.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ message: "relay unavailable" }, { status: 503 }), + ); + }), + ), + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ readonly _tag?: string }>( + relayConfigResponse, + ); + + assert.equal(relayConfigResponse.status, 500); + assert.equal(relayConfigBody._tag, "EnvironmentHttpInternalServerError"); + assert.equal(relayRequests.length, 3); + assert.deepEqual(appliedRuntimeConfigs, [null]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "queues recovery without starting a connector when relay registration requires it", + () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + const requestedRecoveryConfigs: Array = []; + const relayRequests: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ status: "running", providerKind: "cloudflare_tunnel", pid: 123 } as const); + }), + requestRecovery: (config) => + Effect.sync(() => { + requestedRecoveryConfigs.push(config); + }), + }, + httpClient: HttpClient.make((request) => + Effect.sync(() => { + relayRequests.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ status: "recovery_required" }), + ); + }), + ), + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/connect/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + }), + }); + const relayConfigBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly endpointRuntimeStatus?: { readonly status?: string }; + }>(relayConfigResponse); + + assert.equal(relayConfigResponse.status, 503); + assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); + assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "disabled"); + assert.equal(relayRequests.length, 1); + assert.deepEqual(appliedRuntimeConfigs, [null]); + assert.deepEqual(requestedRecoveryConfigs, [ + { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("fails relay config when the managed endpoint connector cannot start", () => Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; yield* buildAppUnderTest({ layers: { cloudManagedEndpointRuntime: { - applyConfig: () => - Effect.succeed({ - status: "failed", - providerKind: "cloudflare_tunnel", - reason: "cloudflared missing", - tunnelId: "tunnel-1", + applyConfig: (config) => + Effect.sync(() => { + appliedRuntimeConfigs.push(config); + return config === null + ? ({ status: "disabled" } as const) + : ({ + status: "failed", + providerKind: "cloudflare_tunnel", + failure: "not-installed", + reason: "cloudflared missing", + tunnelId: "tunnel-1", + } as const); }), }, + httpClient: HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ status: "ready" }))), + ), }, }); @@ -3743,33 +4157,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started."); assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed"); assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing"); - - const now = yield* DateTime.now; - const healthRequest = makeCloudEnvironmentHealthRequest({ - privateKey: cloudKeyPair.privateKey, - environmentId: testEnvironmentDescriptor.environmentId, - nonce: "cloud-health-after-failed-runtime", - issuedAt: DateTime.formatIso(now), - expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), - }); - const healthUrl = yield* getHttpServerUrl("/api/t3-connect/health"); - const healthResponse = yield* fetchEffect(healthUrl, { - method: "POST", - headers: { - "content-type": "application/json", + assert.deepEqual(appliedRuntimeConfigs, [ + null, + { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", }, - body: jsonRequestBody(healthRequest), - }); - const healthBody = yield* responseJsonEffect<{ - _tag?: string; - message?: string; - }>(healthResponse); - assert.equal(healthResponse.status, 500); - assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError"); - assert.equal( - healthBody.message, - "Cloud mint public key is not installed for this environment.", - ); + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5095,7 +5490,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { attributes: [ { key: "service.name", - value: { stringValue: "t3-web" }, + value: { stringValue: "t3code-web" }, }, ], }, @@ -5237,7 +5632,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "rpc.method": "server.getSettings", }, resourceAttributes: { - "service.name": "t3-web", + "service.name": "t3code-web", }, scope: { name: "effect", @@ -5368,7 +5763,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { // the stub's utf8 decode even though the surrounding bytes don't. assert.notEqual(forwarded.body[0], "{"); assert.include(forwarded.body, "client.protobuf.test"); - assert.include(forwarded.body, "t3-web"); + assert.include(forwarded.body, "t3code-web"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5469,11 +5864,61 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(record.links, []); assert.equal(record.scope.name, scopeSpan.scope.name); assert.deepEqual(record.scope.attributes, {}); - assert.equal(record.resourceAttributes["service.name"], "t3-web"); + assert.equal(record.resourceAttributes["service.name"], "t3code-web"); assert.equal(record.status?.code, String(span.status.code)); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("does not trace browser OTLP trace exports on the server", () => + Effect.gen(function* () { + const spanNames: Array = []; + const forwardedUrls: Array = []; + yield* buildAppUnderTest({ + config: { otlpTracesUrl: "http://collector.test/v1/traces" }, + layers: { + httpClient: HttpClient.make((request) => + Effect.sync(() => { + forwardedUrls.push(request.url); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })); + }), + ), + }, + }).pipe( + Effect.provideService( + Tracer.Tracer, + Tracer.make({ + span: (options) => { + spanNames.push(options.name); + return new Tracer.NativeSpan(options); + }, + }), + ), + ); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + spanNames.length = 0; + + // The query string must not bring back the HTTP server span. + for (const url of ["/api/observability/v1/traces", "/api/observability/v1/traces?x=1"]) { + const response = yield* HttpClient.post(url, { + headers: { cookie, "content-type": "application/json" }, + body: yield* HttpBody.json({ resourceSpans: [] }), + }); + assert.equal(response.status, 204); + } + + assert.deepEqual(forwardedUrls, [ + "http://collector.test/v1/traces", + "http://collector.test/v1/traces", + ]); + assert.deepEqual(spanNames, []); + + // Other routes keep their HTTP server span. + const session = yield* HttpClient.get("/api/auth/session", { headers: { cookie } }); + assert.equal(session.status, 200); + assert.include(spanNames, "http.server GET"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { @@ -6371,6 +6816,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates T3 caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { + expiresAt: Number.MAX_SAFE_INTEGER, + version: "1.0.0", + }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerInstanceRegistry: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe( + Effect.provideService(ProviderVersionCache, versionCache), + Effect.provide(NodeHttpServer.layerTest), + ); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); @@ -8826,8 +9364,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 1, thread }), }, }, }); @@ -9000,16 +9537,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { streamDomainEvents: Stream.concat(Stream.make(event), Stream.never), }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 1, thread }), getThreadShellById: (threadId) => - Effect.succeed( - Option.some({ - ...makeDefaultOrchestrationThreadShell(), - id: threadId, - title: "Build complete", - }), - ), + Effect.succeedSome({ + ...makeDefaultOrchestrationThreadShell(), + id: threadId, + title: "Build complete", + }), }, }, }); @@ -9287,7 +9821,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })), + Effect.succeedSome({ snapshotSequence: 100_000, thread }), }, }, }); @@ -9605,8 +10139,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 5, thread }), }, }, }); @@ -9694,7 +10227,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getThreadDetailSnapshot: (_threadId, options) => { requestedTurnLimit = options?.turnLimit; - return Effect.succeed(Option.some({ snapshotSequence: 5, thread })); + return Effect.succeedSome({ snapshotSequence: 5, thread }); }, }, }, @@ -9787,7 +10320,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { readEvents: store.readFromSequence, }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeedNone, }, }, }); @@ -10017,8 +10550,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { replayStatsCalls += 1; return { eventCount: 5, payloadBytes: 8 * 1024 * 1024 + 1 }; }), - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 5, thread }), getShellSnapshot: () => Effect.succeed({ snapshotSequence: 5, @@ -10322,7 +10854,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]), }, projectionSnapshotQuery: { - getThreadShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, }, }, }); @@ -10380,9 +10912,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { detail: "transient failure", }), ) - : Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), - ); + : Effect.succeedSome(makeDefaultOrchestrationThreadShell({ id: threadId })); }), }, }, @@ -10442,7 +10972,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]), }, projectionSnapshotQuery: { - getProjectShellById: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, }, }, }); @@ -10488,22 +11018,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10636,8 +11164,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ id: threadId, session: null }), ), }, }, @@ -10690,22 +11218,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "stopped", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10756,22 +11282,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10859,22 +11383,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10931,22 +11453,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3fd0bb7274a0..43bba34bd8c5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -8,12 +8,16 @@ import { ProviderDriverKind, type RepositoryIdentity, } from "@t3tools/contracts"; +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Random from "effect/Random"; import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -29,6 +33,7 @@ import { staticAndDevRouteLayer, browserApiCorsLayer, httpCompressionLayer, + untracedRequestsLayer, } from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; @@ -46,10 +51,11 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; +import { CodexInstallation } from "./provider/CodexInstallation.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; @@ -115,20 +121,32 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; +import * as HeapSnapshot from "./observability/HeapSnapshot.ts"; +import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, pendingServiceUpdateExists, - reconcileDesiredCloudLink, + reconcileDesiredCloudLinkIfStillDesired, + recoverManagedCloudTunnel, + registerManagedCloudTunnelRecovery, + startManagedCloudTunnelIfOriginConfirmed, releaseManagedTunnelOnShutdown, } from "./cloud/http.ts"; import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; import { shouldRetryCloudLink } from "./cloud/relayResponse.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import { + MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER, + MANAGED_TUNNEL_RECOVERY_COOLDOWN, + managedTunnelStartupAction, + retryManagedTunnelRegistration, +} from "./cloud/managedTunnelStartup.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; @@ -169,7 +187,8 @@ export const HTTP_ROUTER_CONFIG = { // those finalizers get a chance to run. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const ResourceAttributionLayerLive = ResourceAttribution.layer; -const ApplicationObservabilityLive = ObservabilityLive.pipe( +const ApplicationObservabilityLive = EventLoopMonitor.layer.pipe( + Layer.provideMerge(ObservabilityLive), Layer.provideMerge(ResourceAttributionLayerLive), ); @@ -457,22 +476,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const AntigravityInstallationRefreshLive = Layer.effectDiscard( +const ProviderInstallationRefreshLive = Layer.effectDiscard( Effect.gen(function* () { - const installation = yield* AntigravityInstallation; + const antigravity = yield* AntigravityInstallation; + const codex = yield* CodexInstallation; const instances = yield* ProviderInstanceRegistry; const providers = yield* ProviderRegistry; - yield* installation.changes.pipe( - Stream.map((state) => state.installedVersion), - Stream.changes, - Stream.drop(1), - Stream.runForEach(() => + yield* Stream.merge( + antigravity.changes.pipe( + Stream.changesWith((a, b) => a.installedVersion === b.installedVersion), + Stream.drop(1), + ), + codex.changes.pipe( + Stream.changesWith((a, b) => a.installedVersion === b.installedVersion), + Stream.drop(1), + ), + ).pipe( + Stream.runForEach((state) => instances.listInstances.pipe( Effect.flatMap((entries) => Effect.forEach( - entries.filter( - (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"), - ), + entries.filter((instance) => instance.driverKind === state.driver), (instance) => providers.refreshInstance(instance.instanceId), { discard: true }, ), @@ -485,7 +509,8 @@ const AntigravityInstallationRefreshLive = Layer.effectDiscard( ); const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( - Layer.provideMerge(AntigravityInstallationRefreshLive), + Layer.provideMerge(ProviderInstallationRefreshLive), + Layer.provideMerge(ReplayMarkers.layer), Layer.provideMerge(ProviderAuthServiceLive), // Core Services Layer.provideMerge(ServerSettingsLayerLive), @@ -513,7 +538,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), ).pipe( - Layer.provideMerge(AntigravityInstallation.layer), + Layer.provideMerge(Layer.mergeAll(AntigravityInstallation.layer, CodexInstallation.layer)), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). @@ -523,7 +548,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // from the repo's `model-manifest.json` on `main` and applied by the // Codex/Claude drivers. Layer.provideMerge( - Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, CodexResetCredit.layer), + Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but @@ -587,6 +612,8 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), + // Last, so no route layer can replace the server's one TracerDisabledWhen. + untracedRequestsLayer, ).pipe( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. @@ -708,10 +735,6 @@ const makeServerLayer = Layer.unwrap( : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { - if (!hasCloudPublicConfig) { - yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); - return; - } const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( Effect.timeout("10 seconds"), Effect.tap((released) => @@ -740,33 +763,184 @@ const makeServerLayer = Layer.unwrap( if (!cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } - if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; + const localOrigin = `http://127.0.0.1:${address.port}`; + const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime; + const recoveryLock = yield* Semaphore.make(1); + let lastRecoveryAtMillis = 0; + const recoverManagedTunnel = (config: RelayManagedEndpointRuntimeConfig) => + recoveryLock.withPermits(1)( + Effect.gen(function* () { + const elapsed = (yield* Clock.currentTimeMillis) - lastRecoveryAtMillis; + const wait = Duration.toMillis(MANAGED_TUNNEL_RECOVERY_COOLDOWN) - elapsed; + if (wait > 0) yield* Effect.sleep(Duration.millis(wait)); + lastRecoveryAtMillis = yield* Clock.currentTimeMillis; + }).pipe( + Effect.andThen( + recoverManagedCloudTunnel(localOrigin, config, { + retryRuntimeFailures: true, + }), + ), + Effect.retry({ + while: (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.jittered, + ), + }), + Effect.tap((recovered) => + recovered ? Effect.logInfo("T3 Connect managed tunnel recovered") : Effect.void, + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to recover the T3 Connect managed tunnel", { + cause, + }), + ), + ), + ); + yield* endpointRuntime.recoveryRequests.pipe( + Stream.runForEach(recoverManagedTunnel), + Effect.forkScoped, + ); // No settling delay before the first attempt: routes are already // serving by the time activation opens this gate (the startup // sequence awaits routesReady), and the retry schedule below // covers anything this sleep used to hedge against. Every // millisecond here is dead time on the path to remote // reachability after a restart. - yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( - Effect.retry({ - while: shouldRetryCloudLink, - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), + const wantsCliLink = hasCloudPublicConfig + ? yield* CloudCliState.readCliDesiredCloudLink.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read the desired T3 Connect link", { cause }).pipe( + Effect.as(false), + ), ), - Schedule.upTo({ duration: "10 minutes" }), - ), - }), - Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), + ) + : false; + // A failed read must not end this fiber before it registers + // recovery and starts consuming recovery requests. "managed" is + // what a missing value means, so it is the safe fallback. + const desiredCliLinkMode = wantsCliLink + ? yield* CloudCliState.readCliDesiredLinkMode.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read the desired T3 Connect link mode", { + cause, + }).pipe(Effect.as("managed" as const)), + ), + ) + : null; + // A publish-only link must not expose the host, even if a managed + // config from an earlier link is still stored. + const startedConfirmed = + desiredCliLinkMode === "publish_only" + ? false + : yield* startManagedCloudTunnelIfOriginConfirmed(localOrigin).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to start the confirmed T3 Connect tunnel", { + cause, + }).pipe(Effect.as(false)), + ), + ); + const startStoredManagedTunnel = startManagedCloudTunnelIfOriginConfirmed(localOrigin, { + requireConfirmedOrigin: false, + }).pipe( + Effect.tap((started) => + started + ? Effect.logWarning( + "T3 Connect started the stored tunnel without relay confirmation", + ) + : Effect.void, + ), Effect.catch((cause) => - Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { - message: cause.message, - }), + Effect.logWarning("Failed to start the stored T3 Connect tunnel", { cause }), + ), + Effect.asVoid, + ); + const registerManagedTunnel = retryManagedTunnelRegistration( + registerManagedCloudTunnelRecovery(localOrigin, { + retryRuntimeFailures: true, + }), + (error) => + shouldRetryCloudLink(error) && + error._tag !== "EnvironmentCloudEndpointUnavailableError", + startedConfirmed ? Effect.void : startStoredManagedTunnel, + ).pipe( + Effect.tap((result) => + result.status === "ready" + ? Effect.logInfo("T3 Connect managed tunnel recovery registered") + : Effect.void, + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.logWarning("Failed to register T3 Connect managed tunnel recovery", { + cause, + }).pipe(Effect.as({ status: "unavailable" as const })), ), ); + // A host without a confirmed marker is on its first boot after the + // upgrade. Spread those registrations so an auto-update wave does + // not hit the relay all at once. + if (!startedConfirmed && desiredCliLinkMode !== "publish_only") { + const jitter = yield* Random.nextIntBetween( + 0, + Duration.toMillis(MANAGED_TUNNEL_FIRST_REGISTRATION_JITTER), + ); + yield* Effect.sleep(Duration.millis(jitter)); + } + const registration = + desiredCliLinkMode === "publish_only" + ? { status: "not_linked" as const } + : yield* registerManagedTunnel; + // A terminal registration failure also allows the stored config + // to start. Transient outages use the fallback above and keep + // registration retrying in this scoped startup fiber. + if (registration.status === "unavailable" && !startedConfirmed) { + yield* startStoredManagedTunnel; + } + const startupAction = managedTunnelStartupAction({ wantsCliLink, registration }); + if (startupAction.action === "request_recovery") { + yield* endpointRuntime.requestRecovery(startupAction.config); + } + if (startupAction.action === "reconcile_link") { + const reconciledMode = yield* reconcileDesiredCloudLinkIfStillDesired( + localOrigin, + ).pipe( + Effect.retry({ + while: shouldRetryCloudLink, + schedule: Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), + Schedule.upTo({ duration: "10 minutes" }), + ), + }), + Effect.tap((mode) => + mode === null + ? Effect.void + : Effect.logInfo("T3 Connect desired link reconciled on startup"), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { + cause, + }).pipe(Effect.as(null)), + ), + ); + if (reconciledMode === "managed") { + const afterReconcile = yield* registerManagedTunnel; + if (afterReconcile.status === "recovery_required") { + yield* endpointRuntime.requestRecovery(afterReconcile.config); + } + } + } }), ); yield* Deferred.succeed(cloudLinkParked, undefined).pipe(Effect.orDie); @@ -797,6 +971,7 @@ const makeServerLayer = Layer.unwrap( runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, cloudDesiredLinkReconcileLayer, + HeapSnapshot.layer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index cbb5056ed314..43843b249eea 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -1,6 +1,7 @@ import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeOS from "node:os"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -9,6 +10,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; @@ -56,7 +58,7 @@ const configLayer = (overrides: Partial) = otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, ...derivedPaths, @@ -144,11 +146,38 @@ describe("ServerLoggerLive", () => { const [request] = requests; assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "t3code-server"); assert.include(request?.body ?? "", "service.runtime"); }), ); + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-server"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }), + ); + it.effect("stays off the network when no logs endpoint is configured", () => Effect.gen(function* () { const requests = yield* logThrough({}); diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 37fd210ee6da..050650b11515 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -399,18 +399,16 @@ it.effect("does not continue archived or deleted marked sessions", () => { directory: { getBinding: (threadId) => { const thread = threadId === archived.id ? archived : deleted; - return Effect.succeed( - Option.some({ - threadId, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: "running" as const, - resumeCursor: { cursor: threadId }, - runtimePayload: { - continueAfterServerUpdate: thread.session.activeTurnId, - }, - }), - ); + return Effect.succeedSome({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: threadId }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }); }, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), @@ -456,18 +454,16 @@ it.effect("retries continuation preparation before settling a persistent failure threads: [thread], directory: { getBinding: () => - Effect.succeed( - Option.some({ - threadId: thread.id, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: "running" as const, - resumeCursor: { cursor: thread.id }, - runtimePayload: { - continueAfterServerUpdate: thread.session.activeTurnId, - }, - }), - ), + Effect.succeedSome({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: thread.id }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }), upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -610,16 +606,14 @@ it.effect( directory: { getBinding: (candidate) => candidate === absent.id - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : candidate === corrupt.id ? Effect.fail(corruptFailure) - : Effect.succeed( - Option.some({ - threadId: candidate, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - }), - ), + : Effect.succeedSome({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + }), upsert: () => Effect.fail(writeFailure), recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -657,7 +651,7 @@ it.effect("retries failed projections and continues after a persistent failure", return runReconciliation({ threads: [transient, persistent, later], directory: { - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -758,19 +752,17 @@ for (const scenario of [ continueAfterRestart: scenario !== "disabled", directory: { getBinding: () => - Effect.succeed( - Option.some({ - threadId: thread.id, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: scenario === "stopped binding" ? "stopped" : "running", - ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), - runtimePayload: { - activeTurnId: scenario === "marked superseded turn" ? "another-turn" : turnId, - ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), - }, - }), - ), + Effect.succeedSome({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: scenario === "stopped binding" ? "stopped" : "running", + ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), + runtimePayload: { + activeTurnId: scenario === "marked superseded turn" ? "another-turn" : turnId, + ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), + }, + }), upsert: (binding) => Effect.sync(() => { upserts.push(binding); @@ -944,9 +936,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.gen(function* () { sends.push(input); preparedPayloads.push(binding.runtimePayload); - return yield* Effect.fail( - new ProviderSessionNotFoundError({ threadId: input.threadId }), - ); + return yield* new ProviderSessionNotFoundError({ threadId: input.threadId }); }), }, directory: { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ab654e15c70b..4bbdb2e9c8b5 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -169,32 +169,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => - Effect.succeed( - Option.some({ - id: bootstrapProjectId, - title: "Startup Project", - workspaceRoot: "/tmp/startup-project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getFirstActiveThreadIdByProjectId: () => Effect.succeedSome(bootstrapThreadId), getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -300,6 +299,7 @@ it.effect.each([ getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), @@ -321,10 +321,10 @@ it.effect.each([ ), getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -387,17 +387,18 @@ it.effect( getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -452,17 +453,18 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getDeletedWorktreeThreads: () => Effect.die("unused"), + listThreadsWithPullRequests: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 42a8c4470886..a065ef65dc34 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; +import { flushCompileCache } from "./compileCache.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -264,13 +265,13 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { bootstrapThreadId = existingThreadId.value; } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("startup thread auto-bootstrap failed", { - bootstrapProjectId: nextProjectId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), ), ); }); @@ -312,11 +313,9 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; - return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( - Effect.flatMap((target) => - target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), - ), - ); + return serverConfig.mode === "desktop" + ? baseTarget + : yield* serverAuth.issueStartupPairingUrl(baseTarget); }); const maybeOpenBrowser = (target: string) => @@ -487,7 +486,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settings = yield* ServerSettings.ServerSettingsService; const restartSettings = yield* settings.getSettings.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( Effect.as(Option.none()), @@ -549,13 +548,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { continue; } const binding = yield* directory.getBinding(thread.id).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to read orphaned provider session directory binding", { - threadId: thread.id, - cause, - }).pipe(Effect.as(Option.none())), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to read orphaned provider session directory binding", { + threadId: thread.id, + cause, + }).pipe(Effect.as(Option.none())), ), ); const continuationMarkerPresent = @@ -606,13 +605,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { }); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "failed to reconcile orphaned provider session directory binding", - { threadId: thread.id, cause }, - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to reconcile orphaned provider session directory binding", { + threadId: thread.id, + cause, + }), ), ); @@ -633,13 +632,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { }); }).pipe( Effect.retry({ times: 1 }), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to settle orphaned provider session projection", { - threadId: thread.id, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to settle orphaned provider session projection", { + threadId: thread.id, + cause, + }), ), ); }); @@ -739,10 +738,9 @@ export const reconcileProviderSessions = Effect.gen(function* () { yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider session startup reconciliation failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logWarning("provider session startup reconciliation failed", { cause }), ), ); @@ -813,21 +811,20 @@ export const reconcileWorktreeSetups = Effect.gen(function* () { createdAt: interruptedAt, }) .pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to settle interrupted worktree setup", { - threadId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to settle interrupted worktree setup", { + threadId, + cause, + }), ), ); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("worktree setup startup reconciliation failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logWarning("worktree setup startup reconciliation failed", { cause }), ), ); @@ -1078,6 +1075,7 @@ export const make = (options?: StartupOptions) => }), ); yield* Effect.logDebug("startup phase: complete"); + yield* flushCompileCache; }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 4afe10bd5b65..26535ae348a2 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -142,7 +142,7 @@ export const readPersistedServerRuntimeState = (path: string) => cause, }), ), - onSuccess: (contents) => Effect.succeed(Option.some(contents)), + onSuccess: (contents) => Effect.succeedSome(contents), }), ); if (Option.isNone(raw)) { @@ -155,7 +155,7 @@ export const readPersistedServerRuntimeState = (path: string) => } return yield* decodePersistedServerRuntimeState(trimmed).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.mapError( (cause) => new ServerRuntimeStateError({ diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index b2caba4a0347..502ae3430e27 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () => ), ); +/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */ +const makeServerSettingsLayerWithSecrets = () => + ServerSettingsModule.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) => Layer.succeed( ServerSecretStore.ServerSecretStore, @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect( + "keeps Bitbucket tokens in the secret store and tells clients only that one is set", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + + const saved = yield* serverSettings.updateSettings({ + bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" }, + }); + assert.deepEqual(saved.bitbucket, { + email: "me@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "bb-access"); + assert.notInclude(raw, "bb-api"); + assert.include(raw, "me@example.com"); + + const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket; + assert.equal(forClient.email, "me@example.com"); + assert.notInclude(forClient.accessToken, "bb-access"); + assert.notInclude(forClient.apiToken, "bb-api"); + assert.isAbove(forClient.accessToken.length, 0); + assert.isAbove(forClient.apiToken.length, 0); + + // A client echoing the redacted values back, or omitting them, keeps the saved tokens. + yield* serverSettings.updateSettings({ bitbucket: forClient }); + yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } }); + assert.deepEqual((yield* serverSettings.getSettings).bitbucket, { + email: "other@example.com", + accessToken: "bb-access", + apiToken: "bb-api", + }); + + const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } }); + assert.equal(cleared.bitbucket.accessToken, ""); + assert.equal(cleared.bitbucket.apiToken, "bb-api"); + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken, + "", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // A token was saved, then the user deleted it from settings.json directly. + yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token")); + yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); + + yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true }); + + assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token"))); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"accessToken":"hand-edited-token"}}', + ); + + // Loading alone moves it: no settings update is needed. + const loaded = yield* serverSettings.getSettings; + + assert.equal(loaded.bitbucket.accessToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + const stored = yield* secrets.get("bitbucket-access-token"); + assert.equal( + Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null, + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())), + ); + + it.effect( + "moves a hand-edited Bitbucket token into the secret store when a client echoes the marker", + () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}', + ); + + // The form resends the redacted token when only the email changes. + const forClient = ServerSettingsModule.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ).bitbucket; + const updated = yield* serverSettings.updateSettings({ + bitbucket: { email: "new@example.com", apiToken: forClient.apiToken }, + }); + + assert.equal(updated.bitbucket.apiToken, "hand-edited-token"); + assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token"); + assert.notInclude( + yield* fileSystem.readFileString(serverConfig.settingsPath), + "hand-edited-token", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("materializes provider secrets for terminal environment resolution", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..6949a66d981b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: { } /** - * On disk the hub key is replaced by this marker and the real value lives in - * the secret store, mirroring provider environment secrets. A client that - * sends the marker back means "keep what you have". + * On disk a hub key or Bitbucket token is replaced by this marker and the + * real value lives in the secret store, mirroring provider environment + * secrets. A client that sends the marker back means "keep what you have". */ -const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; +const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } +const BITBUCKET_SECRET_NAMES = { + accessToken: "bitbucket-access-token", + apiToken: "bitbucket-api-token", +} as const; +const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const; + +const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : ""); + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS id, { ...source, - managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "", + managementKey: redactSecret(source.managementKey), }, ]), ); - return { ...settings, providerInstances, usageLimitSources }; + const bitbucket = { + ...settings.bitbucket, + accessToken: redactSecret(settings.bitbucket.accessToken), + apiToken: redactSecret(settings.bitbucket.apiToken), + }; + return { ...settings, providerInstances, usageLimitSources, bitbucket }; } export class ServerSettingsService extends Context.Service< @@ -555,6 +568,35 @@ const make = Effect.gen(function* () { ), ); + /** + * Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load, + * so plaintext does not stay on disk. If the store is unavailable, the token keeps working + * from the file and the move is retried on the next load. + */ + const moveInlineBitbucketTokens = (settings: ServerSettings) => + Effect.gen(function* () { + const bitbucket = { ...settings.bitbucket }; + let moved = false; + for (const field of BITBUCKET_SECRET_FIELDS) { + const value = bitbucket[field]; + if (value.length === 0 || value === SECRET_REDACTED) continue; + const stored = yield* secretStore + .set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value)) + .pipe( + Effect.as(true), + Effect.catch(() => + Effect.logWarning("failed to move a Bitbucket token into the secret store", { + field, + }).pipe(Effect.as(false)), + ), + ); + if (!stored) continue; + bitbucket[field] = SECRET_REDACTED; + moved = true; + } + return moved ? { ...settings, bitbucket } : settings; + }); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; @@ -639,10 +681,12 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - if (folded !== loaded) { - yield* writeSettingsAtomically(folded); + // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. + const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; + if (migrated !== loaded) { + yield* writeSettingsAtomically(migrated); } - return folded; + return migrated; }); const settingsCache = yield* Cache.make({ @@ -693,7 +737,7 @@ const make = Effect.gen(function* () { } const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) { - if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey !== SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -709,10 +753,23 @@ const make = Effect.gen(function* () { managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }; } + const bitbucket = { ...settings.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + if (bitbucket[field] !== SECRET_REDACTED) continue; + const secret = yield* secretStore + .get(BITBUCKET_SECRET_NAMES[field]) + .pipe( + Effect.mapError( + (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }), + ), + ); + bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : ""; + } return { ...settings, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }; }); @@ -829,7 +886,7 @@ const make = Effect.gen(function* () { const usageLimitSources: Record = {}; for (const [sourceId, source] of Object.entries(next.usageLimitSources)) { const secretName = usageLimitSourceSecretName(sourceId); - if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) { + if (source.managementKey === SECRET_REDACTED) { usageLimitSources[sourceId] = source; continue; } @@ -843,7 +900,7 @@ const make = Effect.gen(function* () { secretName, value: textEncoder.encode(source.managementKey), }); - usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED }; + usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED }; } for (const sourceId of Object.keys(current.usageLimitSources)) { if (sourceId in next.usageLimitSources) continue; @@ -854,11 +911,31 @@ const make = Effect.gen(function* () { }); } + const bitbucket = { ...next.bitbucket }; + for (const field of BITBUCKET_SECRET_FIELDS) { + let value = bitbucket[field]; + if (value === SECRET_REDACTED) { + // The marker keeps what is saved. A plaintext value hand-edited into settings.json + // is not in the secret store yet, so move it there instead of dropping it. + const inline = current.bitbucket[field]; + if (inline === SECRET_REDACTED || inline.length === 0) continue; + value = inline; + } + const secretName = BITBUCKET_SECRET_NAMES[field]; + if (value.length === 0) { + changes.push({ kind: "remove", secretName, operation: "remove-secret" }); + continue; + } + changes.push({ kind: "write", secretName, value: textEncoder.encode(value) }); + bitbucket[field] = SECRET_REDACTED; + } + return { settings: { ...next, providerInstances: providerInstances as ServerSettings["providerInstances"], usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"], + bitbucket, }, changes, }; diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index b27d56bef399..83965a38b21c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -16,6 +16,7 @@ import { import { GitCommandError } from "@t3tools/contracts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; @@ -64,6 +65,7 @@ function makeLayer(input: { request: HttpClientRequest.HttpClientRequest, ) => HttpClientError.HttpClientError; readonly git?: Partial; + readonly env?: Record; }) { const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => input.requestFailure @@ -150,7 +152,7 @@ function makeLayer(input: { Layer.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ - env: { + env: input.env ?? { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0", T3CODE_BITBUCKET_EMAIL: "user@example.com", T3CODE_BITBUCKET_API_TOKEN: "token", @@ -158,6 +160,7 @@ function makeLayer(input: { }), ), ), + Layer.provideMerge(ServerSettings.layerTest()), Layer.provideMerge(NodeServices.layer), ); @@ -509,6 +512,78 @@ it.effect("reports auth status through the Bitbucket REST /user endpoint", () => }).pipe(Effect.provide(layer)); }); +it.effect("prefers credentials saved in settings over the environment, without a restart", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + const lastAuthorization = () => execute.mock.calls.at(-1)?.[0].headers.authorization; + const basic = (user: string, password: string) => `Basic ${btoa(`${user}:${password}`)}`; + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + + yield* settings.updateSettings({ + bitbucket: { email: "saved@example.com", apiToken: "saved-api-token" }, + }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("saved@example.com", "saved-api-token")); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), "Bearer saved-access-token"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "", apiToken: "" } }); + yield* bitbucket.probeAuth; + assert.strictEqual(lastAuthorization(), basic("user@example.com", "token")); + }).pipe(Effect.provide(layer)); +}); + +it.effect("never puts a saved token that is unsafe for an HTTP header on the wire", () => { + const { execute, layer } = makeLayer({ + response: () => Response.json({ username: "bitbucket-user" }), + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + // Fetch would reject this header with an error quoting the token, and that error reaches + // clients. The unusable token is ignored, so the environment credential is used instead. + yield* settings.updateSettings({ bitbucket: { accessToken: "saved\ntoken" } }); + yield* bitbucket.probeAuth; + assert.strictEqual( + execute.mock.calls.at(-1)?.[0].headers.authorization, + `Basic ${btoa("user@example.com:token")}`, + ); + }).pipe(Effect.provide(layer)); +}); + +it.effect("reports saved credentials as configured when Bitbucket cannot confirm them", () => { + const { layer } = makeLayer({ + response: () => new Response(null, { status: 401 }), + env: { T3CODE_BITBUCKET_API_BASE_URL: "https://api.test.local/2.0" }, + }); + + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + const settings = yield* ServerSettings.ServerSettingsService; + + assert.strictEqual((yield* bitbucket.probeAuth).status, "unauthenticated"); + + yield* settings.updateSettings({ bitbucket: { accessToken: "saved-access-token" } }); + assert.deepStrictEqual(yield* bitbucket.probeAuth, { + status: "unknown", + account: Option.none(), + host: Option.some("bitbucket.org"), + detail: Option.some("An access token is configured."), + }); + }).pipe(Effect.provide(layer)); +}); + it.effect("preserves the HTTP client failure without deriving the domain message from it", () => { const transportCause = new Error("socket reset by peer"); let requestFailure: HttpClientError.HttpClientError | undefined; diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 202740680be8..2404e6b497e3 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -7,8 +7,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, NonNegativeInt, TrimmedNonEmptyString, + type BitbucketSettings, type SourceControlProviderAuth, type SourceControlRepositoryCloneUrls, type SourceControlRepositoryVisibility, @@ -28,6 +30,7 @@ import { } from "./bitbucketPullRequests.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import { retryAtFromHeader } from "./SourceControlRateLimit.ts"; @@ -537,24 +540,64 @@ function repositoryOwnerName(repositoryName: string): string { return repositoryName.split("/")[0]?.trim() || "bitbucket"; } -function authFromConfig( - config: Config.Success, -): SourceControlProviderAuth { - if (Option.isSome(config.accessToken)) { +type BitbucketCredential = + | { readonly kind: "access-token"; readonly accessToken: string } + | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; + +/** + * Visible ASCII only. A value the HTTP stack rejects makes it throw an error quoting the whole + * header, and that error travels to clients as a cause, so an unusable token is treated as unset. + */ +const HEADER_SAFE = /^[\x21-\x7e]+$/u; + +function credentialFrom(input: { + readonly accessToken: string; + readonly email: string; + readonly apiToken: string; +}): BitbucketCredential | null { + if (HEADER_SAFE.test(input.accessToken)) { + return { kind: "access-token", accessToken: input.accessToken }; + } + if (HEADER_SAFE.test(input.email) && HEADER_SAFE.test(input.apiToken)) { + return { kind: "api-token", email: input.email, apiToken: input.apiToken }; + } + return null; +} + +/** + * Credentials saved in settings win over the `T3CODE_BITBUCKET_*` environment variables, which + * stay as a fallback. Within each source the access token wins. + */ +function resolveCredential( + settings: BitbucketSettings, + env: Config.Success, +): BitbucketCredential | null { + return ( + credentialFrom(settings) ?? + credentialFrom({ + accessToken: Option.getOrElse(env.accessToken, () => ""), + email: Option.getOrElse(env.email, () => ""), + apiToken: Option.getOrElse(env.apiToken, () => ""), + }) + ); +} + +function authFromCredential(credential: BitbucketCredential | null): SourceControlProviderAuth { + if (credential?.kind === "access-token") { return { status: "unknown", account: Option.none(), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket access token is configured."), + detail: Option.some("An access token is configured."), }; } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { + if (credential?.kind === "api-token") { return { status: "unknown", - account: config.email, + account: Option.some(credential.email), host: Option.some("bitbucket.org"), - detail: Option.some("Bitbucket API token is configured."), + detail: Option.some("An API token is configured."), }; } @@ -563,7 +606,7 @@ function authFromConfig( account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }; } @@ -612,6 +655,7 @@ function responseError( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const fileSystem = yield* FileSystem.FileSystem; const git = yield* GitVcsDriver.GitVcsDriver; @@ -619,15 +663,27 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; - const withAuth = (request: HttpClientRequest.HttpClientRequest) => { - if (Option.isSome(config.accessToken)) { - return request.pipe(HttpClientRequest.bearerToken(config.accessToken.value)); - } - if (Option.isSome(config.email) && Option.isSome(config.apiToken)) { - return request.pipe(HttpClientRequest.basicAuth(config.email.value, config.apiToken.value)); - } - return request; - }; + // Read on every request so credentials saved in settings apply without a restart. + const currentCredential = serverSettings.getSettings.pipe( + Effect.map((settings) => resolveCredential(settings.bitbucket, config)), + Effect.catch((error) => + // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + ), + ); + + const withAuth = (request: HttpClientRequest.HttpClientRequest) => + currentCredential.pipe( + Effect.map((credential) => + credential === null + ? request + : credential.kind === "access-token" + ? request.pipe(HttpClientRequest.bearerToken(credential.accessToken)) + : request.pipe(HttpClientRequest.basicAuth(credential.email, credential.apiToken)), + ), + ); const decodeResponse = ( operation: BitbucketApiOperation, @@ -654,7 +710,8 @@ export const make = Effect.gen(function* () { request: HttpClientRequest.HttpClientRequest, schema: S, ): Effect.Effect => - httpClient.execute(withAuth(request.pipe(HttpClientRequest.acceptJson))).pipe( + withAuth(request.pipe(HttpClientRequest.acceptJson)).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new BitbucketRequestError({ @@ -847,7 +904,8 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyText(input.body, "application/json")); - return httpClient.execute(withAuth(withBody)).pipe( + return withAuth(withBody).pipe( + Effect.flatMap(httpClient.execute), Effect.mapError( (cause): BitbucketApiError => new BitbucketRequestError({ operation: "request", cause }), ), @@ -913,7 +971,7 @@ export const make = Effect.gen(function* () { host: Option.some("bitbucket.org"), detail: Option.none(), })), - Effect.orElseSucceed(() => authFromConfig(config)), + Effect.catch(() => currentCredential.pipe(Effect.map(authFromCredential))), ), listPullRequests: (input) => resolveRepository(input).pipe( diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index e27aa4c7dc88..f1a01056fbd0 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -192,8 +192,7 @@ export const makeDiscovery = Effect.gen(function* () { type: "api", kind: "bitbucket", label: "Bitbucket", - installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", + installHint: "Add a Bitbucket token in Settings → Source Control.", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..eb31572de467 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -155,8 +155,9 @@ describe("GitHubCli.layer", () => { capacity: 2, timeToLive: "1 minute", }); - const results = yield* Effect.all( - ["github.com", "github.example.test"].map((host, index) => + const results = yield* Effect.forEach( + ["github.com", "github.example.test"], + (host, index) => Cache.get(cache, host).pipe( Effect.provideService(GitHubCli.PinnedGitHubCredential, { host, @@ -164,7 +165,6 @@ describe("GitHubCli.layer", () => { credentialFingerprint: `fingerprint-${index}`, }), ), - ), { concurrency: 2 }, ); expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 30aa22995b95..820b9a76c9a2 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -423,7 +423,7 @@ it.effect("reports implemented tools separately from locally available executabl account: Option.none(), host: Option.some("bitbucket.org"), detail: Option.some( - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN, or T3CODE_BITBUCKET_ACCESS_TOKEN.", + "Add a Bitbucket token in Settings → Source Control, or set the T3CODE_BITBUCKET_* environment variables on the server.", ), }), }, diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index d8893b29e089..422b2c991040 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -289,14 +289,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit get, resolveHandle, resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), - discover: Effect.all( - discoverySpecs.map((spec) => + discover: Effect.forEach( + discoverySpecs, + (spec) => probeSourceControlProvider({ spec, process, cwd: config.cwd, }), - ), { concurrency: "unbounded" }, ), }); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 72af05dacf22..400ae635018f 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -419,10 +419,9 @@ export const make = Effect.gen(function* () { }); const worker = yield* makeDrainableWorker(() => sweep().pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("storage cleanup failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => Effect.logWarning("storage cleanup failed", { cause }), ), ), ); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index c68dfbbcd9ae..d68812c17cc6 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -132,7 +132,7 @@ const readIdentityFile = ( filePath: string, ) => fileSystem.readFileString(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => isNotFoundError(cause) @@ -278,7 +278,7 @@ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierFo } const anonymousId = yield* upsertAnonymousId.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ TelemetryIdentityReadError: (error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 80ab2c43e42c..325233bf59d9 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -56,6 +56,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); killed = false; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; constructor(pid: number) { this.pid = pid; @@ -88,6 +89,7 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { + if (this.exitOnSubscribe) callback(this.exitOnSubscribe); this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -113,6 +115,7 @@ class FakePtyAdapter { readonly spawnFailures: Error[] = []; private readonly mode: "sync" | "async"; private nextPid = 9000; + exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; constructor(mode: "sync" | "async" = "sync") { this.mode = mode; @@ -133,6 +136,7 @@ class FakePtyAdapter { ); } const process = new FakePtyProcess(this.nextPid++); + process.exitOnSubscribe = this.exitOnSubscribe; this.processes.push(process); if (this.mode === "async") { return Effect.tryPromise({ @@ -628,6 +632,36 @@ it.layer( }), ); + it.effect("handles an exit replayed during subscription after publishing startup", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + ptyAdapter.exitOnSubscribe = { exitCode: 7, signal: null }; + const { manager, getEvents } = yield* createManager(5, { ptyAdapter }); + const exited = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "exited" + ? Deferred.succeed(exited, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.open(openInput()); + yield* Deferred.await(exited); + const events = yield* getEvents; + expect(events.map((event) => event.type)).toEqual(["started", "exited"]); + expect(events[1]).toMatchObject({ exitCode: 7 }); + const attached: TerminalAttachStreamEvent[] = []; + const stopAttach = yield* manager.attachStream(openInput(), (event) => + Effect.sync(() => { + attached.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(stopAttach)); + expect(attached.find((event) => event.type === "snapshot")).toMatchObject({ + snapshot: { status: "exited", exitCode: 7 }, + }); + }), + ); + it.effect("supports asynchronous PTY spawn effects", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { @@ -1230,6 +1264,73 @@ it.layer( }), ); + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => + Effect.gen(function* () { + // FakePtyAdapter assigns pids from 9000 in open order. + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + // An async prompt worker: a copy of the shell with no children. + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + // A subshell with a child is real work. + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(withHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); + }), + ); + + it.effect("keeps terminals that get input or output while closeIdle checks them", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + // The typed command's process misses the snapshot, but its input or echo lands. + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager, getEvents } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + }).pipe(Effect.orDie); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); + }), + ); + it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => Effect.gen(function* () { const fallbackCalls: Array = []; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9e8f98b2309e..43d6a0750e65 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Close a thread's terminals that wait at an idle shell prompt. A terminal + * that runs a command stays open. When `terminalId` is set, only that + * terminal is considered. Used when a thread settles and when a setup + * script finishes. + */ + readonly closeIdle: (input: { + readonly threadId: string; + readonly terminalId?: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * @@ -275,6 +286,8 @@ interface TerminalSessionState { exitSignal: number | null; updatedAt: string; eventSequence: number; + /** Counts writes, so closeIdle can see input that has not echoed yet. */ + inputCount: number; cols: number; rows: number; process: PtyAdapter.PtyProcess | null; @@ -692,7 +705,17 @@ function deriveSubprocessInspectResult( terminalPid: number, platform: NodeJS.Platform, ): TerminalSubprocessInspectResult { - const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + 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. + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( + (pid) => + shellName === null || + commandName(pid) !== shellName || + (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0, + ); if (childPid === undefined) { return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } @@ -707,7 +730,7 @@ function deriveSubprocessInspectResult( pending.push(pid); } } - const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + const normalized = commandName(childPid); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, @@ -1934,16 +1957,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId: string, ): Effect.fn.Return { return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), + Effect.fromOption(session, () => new TerminalSessionLookupError({ threadId, terminalId })), ); }); @@ -2229,18 +2243,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func startedShell = spawnResult.shellLabel; const processPid = ptyProcess.pid; - const unsubscribeData = ptyProcess.onData((data) => { - if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); - const unsubscribeExit = ptyProcess.onExit((event) => { - if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { - return; - } - runFork(drainProcessEvents(session, processPid)); - }); + let eventsActivated = false; let eventStamp: ReturnType = { updatedAt: session.updatedAt, @@ -2250,8 +2253,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = ptyProcess; session.pid = processPid; session.status = "running"; - session.unsubscribeData = unsubscribeData; - session.unsubscribeExit = unsubscribeExit; + // onExit may replay an exit immediately; accept it before subscribing. + session.unsubscribeData = spawnResult.process.onData((data) => { + if (!enqueueProcessEvent(session, processPid, { type: "output", data })) { + return; + } + if (eventsActivated) runFork(drainProcessEvents(session, processPid)); + }); + session.unsubscribeExit = spawnResult.process.onExit((event) => { + if (!enqueueProcessEvent(session, processPid, { type: "exit", event })) { + return; + } + if (eventsActivated) runFork(drainProcessEvents(session, processPid)); + }); eventStamp = advanceEventSequence(session); return [undefined, state] as const; }); @@ -2263,6 +2277,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: eventStamp.sequence, snapshot: snapshot(session), }); + // Publish startup before draining any events replayed during subscription. + eventsActivated = true; + if (session.processEventDrainRunning) runFork(drainProcessEvents(session, processPid)); }), ), ), @@ -2367,7 +2384,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const inspectorOption = yield* acquireSubprocessInspector.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { reason, @@ -2393,7 +2410,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) { const terminalPid = session.pid; const inspectResult = yield* subprocessInspector(terminalPid).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { threadId: session.threadId, @@ -2546,6 +2563,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -2886,6 +2904,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } + session.inputCount += 1; yield* Effect.try({ try: () => process.write(input.data), catch: (cause) => @@ -2967,6 +2986,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -3051,6 +3071,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const running = (yield* sessionsForThread(input.threadId)).filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && + Number.isInteger(session.pid) && + (input.terminalId === undefined || session.terminalId === input.terminalId), + ); + if (running.length === 0) return; + // A command started during the process check can miss the snapshot, + // but its input or echo still lands. Both counters only grow, so the + // sum changes when either one does. + const activityMark = (session: TerminalSessionState) => + session.eventSequence + session.inputCount; + const marks = new Map( + running.map((session) => [session.terminalId, activityMark(session)]), + ); + // Inspect now instead of trusting the last poll, so a command started + // since then keeps its terminal. + const { inspector } = yield* acquireSubprocessInspector; + yield* Effect.forEach( + running, + (session) => + inspector(session.pid).pipe( + Effect.flatMap((result) => + result.hasRunningSubprocess || + activityMark(session) !== marks.get(session.terminalId) + ? Effect.void + : closeSession(input.threadId, session.terminalId, false), + ), + ), + { discard: true }, + ); + }), + ).pipe( + // The process check failed, so every terminal stays open. + Effect.catch((error) => + Effect.logWarning("failed to close idle terminals", { + threadId: input.threadId, + error: error.message, + }), + ), + ); + return TerminalManager.of({ open, attachStream, @@ -3059,6 +3125,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + closeIdle, subscribe, subscribeMetadata, }); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index e6650025f70f..8107e19e6165 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -1,23 +1,63 @@ +import * as NodeEvents from "node:events"; +import * as NodeNet from "node:net"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; +import * as Logger from "effect/Logger"; +import * as Scheduler from "effect/Scheduler"; +import { expect, vi } from "vite-plus/test"; import * as NodePtyAdapter from "./NodePtyAdapter.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; -const spawn = vi.fn(() => ({ - pid: 42, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onExit: vi.fn(() => ({ dispose: vi.fn() })), -})); +function makeNativeProcess(pid = 42) { + const events = new NodeEvents.EventEmitter(); + return { + pid, + _socket: new NodeNet.Socket(), + _agent: { kill: vi.fn() }, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: vi.fn((callback: (data: string) => void) => { + events.on("data", callback); + return { + dispose: () => { + events.off("data", callback); + }, + }; + }), + onExit: vi.fn((callback: (event: { exitCode: number; signal?: number }) => void) => { + events.on("exit", callback); + return { + dispose: () => { + events.off("exit", callback); + }, + }; + }), + events, + }; +} + +const spawn = vi.fn(() => makeNativeProcess()); + +function preparePendingProcess() { + const nativeProcess = makeNativeProcess(0); + const subscribed = Promise.withResolvers(); + nativeProcess._socket.on("newListener", (event) => { + if (event === "ready_datapipe") queueMicrotask(() => subscribed.resolve()); + }); + spawn.mockReturnValueOnce(nativeProcess); + return { nativeProcess, subscribed: Effect.promise(() => subscribed.promise) }; +} + +const spawnInput = { shell: "powershell.exe", cwd: ".", cols: 80, rows: 24, env: {} }; const fakeNodePty = { spawn } as unknown as typeof import("node-pty"); @@ -35,6 +75,91 @@ const makeTestLayer = (platform: NodeJS.Platform = "win32") => const testLayer = makeTestLayer(); +it.effect("waits for the Windows PID without requiring output", () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + let completed = false; + const fiber = yield* adapter.spawn(spawnInput).pipe( + Effect.tap(() => + Effect.sync(() => { + completed = true; + }), + ), + Effect.forkChild, + ); + yield* subscribed; + assert.isFalse(completed); + nativeProcess.pid = 12345; + nativeProcess._socket.emit("ready_datapipe"); + const process = yield* Fiber.join(fiber); + assert.equal(process.pid, 12345); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 1); + + const output: string[] = []; + const exits: PtyAdapter.PtyExitEvent[] = []; + const stopData = process.onData((data) => output.push(data)); + const stopExit = process.onExit((event) => exits.push(event)); + nativeProcess.events.emit("data", "first output"); + nativeProcess.events.emit("exit", { exitCode: 0 }); + assert.deepEqual(output, ["first output"]); + assert.deepEqual(exits, [{ exitCode: 0, signal: null }]); + stopData(); + stopExit(); + }).pipe(Effect.provide(testLayer)), +); + +for (const failure of ["exit", "close", "error", "invalid-pid"] as const) { + it.effect(`fails Windows startup on ${failure} and cleans up`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.result, Effect.forkChild); + yield* subscribed; + if (failure === "exit") nativeProcess.events.emit("exit", { exitCode: 1 }); + else if (failure === "error") nativeProcess._socket.emit("error", new Error("pipe failed")); + else nativeProcess._socket.emit(failure === "close" ? "close" : "ready_datapipe"); + const result = yield* Fiber.join(fiber); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.instanceOf(result.failure, PtyAdapter.PtySpawnError); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess._socket.listenerCount("error"), 0); + assert.equal(nativeProcess._socket.listenerCount("close"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + }).pipe(Effect.provide(testLayer)), + ); +} + +it.effect("cancels the Windows connection without waiting for output", () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter.spawn(spawnInput).pipe(Effect.forkChild); + yield* subscribed; + yield* Fiber.interrupt(fiber); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + assert.equal(nativeProcess.kill.mock.calls.length, 0); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("reports an incompatible Windows readiness API instead of hanging", () => + Effect.gen(function* () { + const nativeProcess = makeNativeProcess(0); + Reflect.deleteProperty(nativeProcess, "_socket"); + spawn.mockReturnValueOnce(nativeProcess); + const adapter = yield* PtyAdapter.PtyAdapter; + const error = yield* adapter.spawn(spawnInput).pipe(Effect.flip); + assert.instanceOf(error, PtyAdapter.PtySpawnError); + assert.instanceOf(error.cause, Error); + assert.equal(error.cause.message, "Windows PTY readiness socket is unavailable."); + assert.equal(nativeProcess._agent.kill.mock.calls.length, 1); + }).pipe(Effect.provide(testLayer)), +); + for (const platform of ["win32", "linux", "darwin"] as const) { it.effect(`terminates through node-pty using ${platform} semantics`, () => Effect.gen(function* () { @@ -153,3 +278,88 @@ it.effect("reports native module load failures as structured startup defects", ( ), ), ); + +for (const budget of [2048, 8]) { + it.effect(`preserves an exit during readiness handoff with scheduler budget ${budget}`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const adapter = yield* PtyAdapter.PtyAdapter; + const exits: PtyAdapter.PtyExitEvent[] = []; + const fiber = yield* Effect.gen(function* () { + const process = yield* adapter.spawn(spawnInput); + process.onExit((event) => exits.push(event)); + }).pipe( + Effect.provideService(Scheduler.MaxOpsBeforeYield, budget), + Effect.provideService(Scheduler.PreventSchedulerYield, false), + Effect.forkChild, + ); + yield* subscribed; + nativeProcess.pid = 12345; + nativeProcess._socket.emit("ready_datapipe"); + nativeProcess.events.emit("exit", { exitCode: 0 }); + yield* Fiber.join(fiber); + assert.equal(exits.length, 1); + }).pipe(Effect.provide(testLayer)), + ); +} + +it.effect("replays an exit to late subscribers and respects unsubscription", () => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn(spawnInput); + const nativeProcess = spawn.mock.results.at(-1)!.value; + const removed = vi.fn(); + process.onExit(removed)(); + nativeProcess.events.emit("exit", { exitCode: 7, signal: 2 }); + const late = vi.fn(); + process.onExit(late); + nativeProcess.events.emit("exit", { exitCode: 9 }); + assert.equal(removed.mock.calls.length, 0); + assert.deepEqual(late.mock.calls, [[{ exitCode: 7, signal: 2 }]]); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(testLayer)), +); + +for (const failure of ["spawn", "interrupt"] as const) { + it.effect(`logs cleanup failures without replacing ${failure}`, () => + Effect.gen(function* () { + const { nativeProcess, subscribed } = preparePendingProcess(); + const killError = new Error("native kill failed"); + nativeProcess._agent.kill.mockImplementation(() => { + throw killError; + }); + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + const adapter = yield* PtyAdapter.PtyAdapter; + const fiber = yield* adapter + .spawn(spawnInput) + .pipe( + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.forkChild, + ); + yield* subscribed; + const spawnError = new Error("pipe failed"); + if (failure === "interrupt") yield* Fiber.interrupt(fiber); + else nativeProcess._socket.emit("error", spawnError); + const exit = yield* Fiber.await(fiber); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + if (failure === "interrupt") assert.isTrue(Cause.hasInterrupts(exit.cause)); + else { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, PtyAdapter.PtySpawnError); + assert.equal(error.cause, spawnError); + } + } + assert.equal(messages.length, 1); + expect(messages[0]).toMatchObject([ + "failed to cancel Windows terminal startup", + { terminalPid: 0, cause: { cause: killError } }, + ]); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + assert.equal(nativeProcess._socket.listenerCount("ready_datapipe"), 0); + }).pipe(Effect.provide(testLayer)), + ); +} diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 67cdcecdd53a..8ba78ee4d288 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -1,4 +1,5 @@ import * as NodeModule from "node:module"; +import * as NodeNet from "node:net"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -82,13 +83,112 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); +/** + * Waits for Windows process creation so the manager receives a valid PID. + * node-pty defers creation to avoid blocking on named pipes: + * https://github.com/microsoft/node-pty/pull/885 + * T3 adopted that behavior when upgrading from 1.1.0 to 1.2.0-beta.15: + * https://github.com/pingdotgg/t3code/pull/13748 + * Its public API has no readiness event. The private ready_datapipe handler sets + * pid before our listener runs. + */ +const waitForWindowsPid = ( + process: import("node-pty").IPty, + trackedProcess: NodePtyProcess, + shell: string, +) => + Effect.callback((resume) => { + const hasPid = () => Number.isInteger(process.pid) && process.pid > 0; + const failure = (cause: unknown) => + Effect.fail(new PtyAdapter.PtySpawnError({ adapter: "node-pty", shell, cause })); + + if (hasPid()) { + resume(Effect.void); + return; + } + + if (!("_socket" in process) || !(process._socket instanceof NodeNet.Socket)) { + resume(failure(new Error("Windows PTY readiness socket is unavailable."))); + return; + } + + const socket = process._socket; + const onReady = () => { + cleanup(); + resume( + hasPid() + ? Effect.void + : failure(new Error("Windows PTY became ready without a valid PID.")), + ); + }; + const onError = (cause: Error) => { + cleanup(); + resume(failure(cause)); + }; + const onClose = () => onError(new Error("Windows PTY closed before its PID was available.")); + let stopExit = () => {}; + const cleanup = () => { + socket.off("ready_datapipe", onReady); + socket.off("error", onError); + socket.off("close", onClose); + stopExit(); + }; + socket.once("ready_datapipe", onReady); + socket.once("error", onError); + socket.once("close", onClose); + stopExit = trackedProcess.onExit(({ exitCode }) => + onError( + new Error(`Windows PTY exited before its PID was available (exit code ${exitCode}).`), + ), + ); + 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. + * Cleanup failures are logged without replacing the startup failure. + */ +const killStartingWindowsPty = (process: import("node-pty").IPty) => + Effect.try(() => { + if ( + "_agent" in process && + typeof process._agent === "object" && + process._agent !== null && + "kill" in process._agent && + typeof process._agent.kill === "function" + ) { + process._agent.kill(); + } else { + process.kill(); + } + }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to cancel Windows terminal startup", { + terminalPid: process.pid, + cause: error, + }), + ), + ); + class NodePtyProcess implements PtyAdapter.PtyProcess { private readonly process: import("node-pty").IPty; private readonly platform: NodeJS.Platform; + private exitEvent: PtyAdapter.PtyExitEvent | undefined; + private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private readonly exitSubscription: import("node-pty").IDisposable; constructor(process: import("node-pty").IPty, platform: NodeJS.Platform) { this.process = process; this.platform = platform; + // Retain exits while Windows readiness and the manager hand off the process. + this.exitSubscription = process.onExit((event) => { + if (this.exitEvent) return; + this.exitEvent = { exitCode: event.exitCode, signal: event.signal ?? null }; + this.exitSubscription.dispose(); + for (const listener of this.exitListeners) listener(this.exitEvent); + this.exitListeners.clear(); + }); } get pid(): number { @@ -116,16 +216,20 @@ class NodePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - const disposable = this.process.onExit((event) => { - callback({ - exitCode: event.exitCode, - signal: event.signal ?? null, - }); - }); + if (this.exitEvent) { + callback(this.exitEvent); + return () => {}; + } + this.exitListeners.add(callback); return () => { - disposable.dispose(); + this.exitListeners.delete(callback); }; } + + disposeExitSubscription(): void { + this.exitSubscription.dispose(); + this.exitListeners.clear(); + } } export const make = Effect.fn("NodePtyAdapter.make")(function* () { @@ -166,14 +270,16 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { ? { ...input.env, TERM: "xterm-256color" } : input.env; const ptyProcess = yield* Effect.try({ - try: () => - nodePty.spawn(input.shell, input.args ?? [], { + try: () => { + const nativeProcess = nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, env, name: "xterm-256color", - }), + }); + return { nativeProcess, process: new NodePtyProcess(nativeProcess, platform) }; + }, catch: (cause) => new PtyAdapter.PtySpawnError({ adapter: "node-pty", @@ -181,7 +287,16 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* () { cause, }), }); - return new NodePtyProcess(ptyProcess, platform); + if (platform === "win32") { + yield* waitForWindowsPid(ptyProcess.nativeProcess, ptyProcess.process, input.shell).pipe( + Effect.onError(() => + Effect.sync(() => ptyProcess.process.disposeExitSubscription()).pipe( + Effect.andThen(killStartingWindowsPty(ptyProcess.nativeProcess)), + ), + ), + ); + } + return ptyProcess.process; }), }); }); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 6fd59041ddb0..dacc50bf383d 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -238,7 +238,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener yield* applyAntigravityAcpModelSelection({ runtime, model: input.modelSelection.model, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, mapError: (cause) => new TextGenerationError({ operation, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 91c9cb94b5d5..161a18abb2b7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -135,6 +135,7 @@ function withFakeCodexEnv( launchArgs?: string; environment?: NodeJS.ProcessEnv; models?: ReadonlyArray; + managedRuntime?: boolean; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { @@ -154,6 +155,13 @@ function withFakeCodexEnv( capabilities: null, })), ), + input.managedRuntime + ? Effect.succeed({ + config, + environment: input.environment ?? process.env, + revision: "test", + }) + : undefined, ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); @@ -236,6 +244,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); + it.effect("omits a persisted service tier for managed ChatGPT text generation", () => + withFakeCodexEnv( + { + output: JSON.stringify({ subject: "Update project", body: "" }), + managedRuntime: true, + forbidArg: 'service_tier="priority"', + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/chatgpt", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "serviceTier", value: "priority" }, + ]), + }), + ), + ); + it.effect("passes exec-safe launch args into codex exec", () => withFakeCodexEnv( { @@ -556,7 +584,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { }), ), ), - Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.ignore)), ); expect(generated.branch).toBe("fix/ui-regression"); @@ -579,7 +607,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { const { attachmentsDir } = yield* ServerConfig.ServerConfig; const missingAttachmentId = "thread-missing-attachment"; const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); + yield* fs.remove(missingPath).pipe(Effect.ignore); const result = yield* textGeneration .generateBranchName({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4c9ac59d8422..8384aa6e550c 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -48,6 +48,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func codexConfig: CodexSettings, environment?: NodeJS.ProcessEnv, getModels: Effect.Effect> = Effect.succeed([]), + resolveRuntime?: Effect.Effect< + import("../provider/CodexManagedRuntime.ts").CodexEffectiveRuntime, + import("@t3tools/contracts").ProviderSetupError, + Scope.Scope + >, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -96,7 +101,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const safeUnlink = (filePath: string): Effect.Effect => - fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + fileSystem.remove(filePath).pipe(Effect.ignore); const encodeJsonForOperation = ( operation: @@ -180,6 +185,15 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const outputPath = yield* writeTempFile(operation, "codex-output", ""); const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const resolved = resolveRuntime + ? yield* resolveRuntime.pipe( + Effect.mapError( + (cause) => new TextGenerationError({ operation, detail: cause.detail }), + ), + ) + : undefined; + const effectiveConfig = resolved?.config ?? codexConfig; + const effectiveEnvironment = resolved?.environment ?? resolvedEnvironment; const models = yield* getModels; const requestedModel = modelSelection.model; const model = @@ -188,13 +202,13 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel, )?.slug ?? requestedModel; - const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); + const launchArgs = resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment); const reasoningEffort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? DEFAULT_TEXT_GENERATION_REASONING_EFFORT; - const serviceTier = getCodexServiceTierOptionValue(modelSelection); + const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( - codexConfig.binaryPath || "codex", + effectiveConfig.binaryPath || "codex", [ "exec", ...codexExecLaunchArgs(launchArgs), @@ -214,12 +228,14 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], - { env: resolvedEnvironment }, + { env: effectiveEnvironment }, ); const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: { - ...resolvedEnvironment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + ...effectiveEnvironment, + ...(effectiveConfig.homePath + ? { CODEX_HOME: expandHomePath(effectiveConfig.homePath) } + : {}), }, cwd, shell: spawnCommand.shell, @@ -263,8 +279,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } }); - const cleanup = Effect.all( - [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), + const cleanup = Effect.forEach( + [schemaPath, outputPath, ...cleanupPaths], + (filePath) => safeUnlink(filePath), { concurrency: "unbounded", }, diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.ts b/apps/server/src/textGeneration/ThreadTitleLinks.ts index 1b008bb38094..4e22605682f7 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.ts @@ -14,12 +14,9 @@ export const resolveThreadTitleLinks = Effect.fn("resolveThreadTitleLinks")(func const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const links = new Map>>(); for (const match of input.message.matchAll(/https:\/\/[^\s<>"')\]`]+/g)) { - let url: URL; - try { - url = new URL(match[0].replace(/[.,;!?]+$/, "")); - } catch { - continue; - } + const candidate = match[0].replace(/[.,;!?]+$/, ""); + if (!URL.canParse(candidate)) continue; + const url = new URL(candidate); url.hash = ""; url.search = ""; if (links.has(url.href)) continue; @@ -40,7 +37,7 @@ export const resolveThreadTitleLinks = Effect.fn("resolveThreadTitleLinks")(func ), Effect.map((summary) => `${url}\n${summary}`), Effect.timeout("3 seconds"), - Effect.catch(() => Effect.succeed(`${url}: unavailable`)), + Effect.orElseSucceed(() => `${url}: unavailable`), ), { concurrency: 2 }, ); diff --git a/apps/server/src/turbo/officialImport/plan.ts b/apps/server/src/turbo/officialImport/plan.ts index 81b90c158aeb..bb395bae4f86 100644 --- a/apps/server/src/turbo/officialImport/plan.ts +++ b/apps/server/src/turbo/officialImport/plan.ts @@ -517,6 +517,12 @@ export const transformOfficialImportEvent = ( ...base, payload: { ...event.payload, threadId: remap(idMap.threadIds, event.payload.threadId) }, }; + case "thread.auto-settle-set": + return { + ...event, + ...base, + payload: { ...event.payload, threadId: remap(idMap.threadIds, event.payload.threadId) }, + }; case "thread.runtime-mode-set": return { ...event, @@ -765,6 +771,7 @@ const visitOfficialImportEventIdentities = ( case "thread.unsettled": case "thread.snoozed": case "thread.unsnoozed": + case "thread.auto-settle-set": case "thread.runtime-mode-set": case "thread.interaction-mode-set": case "thread.checkpoint-revert-requested": diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index b391e213ab9a..f7286a11b60d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -3,10 +3,11 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -31,6 +32,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { @@ -82,9 +84,11 @@ const serviceLayers = (input: { /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; readonly environment?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -101,7 +105,12 @@ const serviceLayers = (input: { ), Layer.provideMerge( Layer.succeed(HostProcessEnvironment, { + HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), + OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), + ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), + XDG_CONFIG_HOME: NodePath.join(input.home, "config"), + APPDATA: NodePath.join(input.home, "config"), ...input.environment, }), ), @@ -112,6 +121,315 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + for (const explicitDefault of [true, false]) { + it.live( + `reads shared managed ${explicitDefault ? "explicit" : "legacy"} default and disabled extra account history once`, + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providers: { + ...settings.providers, + codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, + }, + providerInstances: { + ...(explicitDefault + ? { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + }, + }, + } + : {}), + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), + }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], + }, + }, + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), + ); + } + it.live("omits Cursor account usage when no file login is saved", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const platform of ["linux", "win32", "darwin"] as const) { + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-no-login-${platform}`, + home, + settings, + platform, + environment: { AGENT_CLI_CREDENTIAL_STORE: "file" }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live("keeps Cursor credential errors visible when a saved login cannot be read", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const authPath = NodePath.join(home, "config", "cursor", "auth.json"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile(authPath, "invalid json"); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-cursor-invalid-login", home, settings }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.message, "Cursor credentials could not be read."); + }).pipe(Effect.scoped), + ); + + it.live("does not read the macOS Cursor Keychain before account usage is enabled", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cursor-keychain-disabled", + home, + settings, + platform: "darwin", + environment: {}, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.strictEqual(cursor?.action, "enableCursorKeychain"); + }).pipe(Effect.scoped), + ); + + it.live("ignores stale Cursor file logins when the active credential store differs", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + for (const [index, testCase] of [ + { + platform: "darwin" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: [".cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { AGENT_CLI_CREDENTIAL_STORE: "memory" }, + authPath: ["config", "cursor", "auth.json"], + }, + { + platform: "linux" as const, + environment: { CURSOR_API_KEY: "different-account" }, + authPath: ["config", "cursor", "auth.json"], + }, + ].entries()) { + const authPath = NodePath.join(home, ...testCase.authPath); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(authPath), { recursive: true }); + await NodeFSP.writeFile( + authPath, + encodeUnknownJsonString({ accessToken: "stale-token" }), + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: `usage-service-cursor-store-${index}`, + home, + settings, + platform: testCase.platform, + environment: testCase.environment, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const cursor = summary.sources.find((source) => source.fingerprint.provider === "cursor"); + assert.strictEqual(cursor?.status, "missing"); + assert.include(cursor?.message ?? "", "Cursor CLI login"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + } + }).pipe(Effect.scoped), + ); + + it.live( + "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", + () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const root = NodePath.join(home, "opencode"); + const message = yield* encodeUnknownJson({ + id: "msg_1", + sessionID: "session-1", + role: "assistant", + modelID: "example-model", + time: { created: Date.parse("2026-08-01T10:00:00Z") }, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 3 } }, + }); + const bubble = yield* encodeUnknownJson({ + type: 2, + createdAt: "2026-08-01T10:00:00Z", + modelInfo: { modelName: "example-model" }, + tokenCount: { inputTokens: 100, outputTokens: 20 }, + }); + yield* Effect.promise(async () => { + const directory = NodePath.join(root, "storage", "message", "session-1"); + await NodeFSP.mkdir(directory, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(directory, "msg_1.json"), message); + const desktop = NodePath.join(home, "config", "Cursor", "User", "globalStorage"); + await NodeFSP.mkdir(desktop, { recursive: true }); + const db = new NodeSqlite.DatabaseSync(NodePath.join(desktop, "state.vscdb")); + try { + db.exec("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)"); + db.prepare("INSERT INTO cursorDiskKV VALUES (?, ?)").run( + "bubbleId:session:assistant", + bubble, + ); + } finally { + db.close(); + } + }); + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-opencode", home, settings })), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(summary.buckets[0]?.provider, "opencode"); + assert.isFalse(summary.buckets.some((bucket) => bucket.provider === "cursor")); + assert.isFalse(summary.sources.some((source) => source.fingerprint.provider === "cursor")); + assert.strictEqual( + summary.buckets[0]?.sourcePath, + yield* Effect.promise(() => NodeFSP.realpath(root)), + ); + assert.strictEqual(summary.buckets[0]?.totals.outputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "opencode") + ?.distinctSessions, + 1, + ); + }).pipe(Effect.scoped), + ); + + it.live("counts aliased OpenCode and Antigravity directories once", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const opencode = NodePath.join(home, "opencode-store"); + const opencodeAlias = NodePath.join(home, "opencode-alias"); + const conversations = NodePath.join(home, "antigravity-conversations"); + const antigravityA = NodePath.join(home, "antigravity-a"); + const antigravityB = NodePath.join(home, "antigravity-b"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(opencode); + await NodeFSP.symlink(opencode, opencodeAlias, "junction"); + await NodeFSP.mkdir(conversations); + await NodeFSP.mkdir(antigravityA); + await NodeFSP.mkdir(antigravityB); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityA, "conversations"), + "junction", + ); + await NodeFSP.symlink( + conversations, + NodePath.join(antigravityB, "conversations"), + "junction", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-aliased-roots-test", + home, + settings, + environment: { + OPENCODE_DATA_DIR: `${opencode},${opencodeAlias}`, + ANTIGRAVITY_DATA_DIR: `${antigravityA},${antigravityB}`, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + const sourcesFor = (provider: "opencode" | "antigravity") => + summary.sources.filter((source) => source.fingerprint.provider === provider); + assert.strictEqual(sourcesFor("opencode").length, 1); + assert.strictEqual(sourcesFor("antigravity").length, 1); + assert.strictEqual( + sourcesFor("opencode")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(opencode)), + ); + assert.strictEqual( + sourcesFor("antigravity")[0]?.fingerprint.resolvedHomePath, + yield* Effect.promise(() => NodeFSP.realpath(conversations)), + ); + }).pipe(Effect.scoped), + ); + it.live("reads configured and disabled accounts once across shared and aliased homes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -258,9 +576,12 @@ describe("UsageService", () => { const service = yield* UsageService.make; const first = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(first), 7); + const configuredProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(configured, "projects")), + ); assert.include( first.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(configured, "projects"), + configuredProjects, ); yield* settingsService.updateSettings({ providerInstances: { @@ -275,9 +596,12 @@ describe("UsageService", () => { }); const second = yield* service.readSummary(WINDOW); assert.strictEqual(totalOutputTokens(second), 8); + const environmentProjects = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(environmentHome, "projects")), + ); assert.include( second.sources.map((source) => source.fingerprint.resolvedHomePath), - NodePath.join(environmentHome, "projects"), + environmentProjects, ); }).pipe( Effect.provide( @@ -410,6 +734,59 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const large = claudeLine(1, 9900).replace( + '"message":', + '"padding":' + encodeUnknownJsonString("x".repeat(9 * 1024 * 1024)) + ',"message":', + ); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, large)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 9900); + assert.closeTo( + first.buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + 0.4951, + 1e-12, + ); + const warm = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(warm.buckets, first.buckets); + // The repeated content block has the same message/request identity. + yield* Effect.promise(() => NodeFSP.appendFile(transcript, large + claudeLine(2, 100))); + const appended = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(appended), 10000); + assert.strictEqual( + appended.buckets.reduce((sum, bucket) => sum + bucket.totals.uncachedInputTokens, 0), + 20, + ); + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, appended.buckets); + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const afterCleanup = yield* UsageService.make; + assert.deepStrictEqual( + (yield* afterCleanup.readSummary(WINDOW)).buckets, + appended.buckets, + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-large-record-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -505,6 +882,9 @@ describe("UsageService", () => { Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + const transcriptDir = yield* Effect.promise(() => + NodeFSP.realpath(NodePath.join(home, "claude", "projects")), + ); yield* Effect.gen(function* () { const settingsService = yield* ServerSettings.ServerSettingsService; @@ -519,7 +899,7 @@ describe("UsageService", () => { exists: (path) => fileSystem.exists(path).pipe( Effect.tap(() => { - if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; + if (path !== transcriptDir) return Effect.void; homeProbes += 1; return Deferred.succeed( homeProbes === 1 ? firstScanStarted : secondScanStarted, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 949155c650f2..9e8cda9960ea 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,14 +1,14 @@ /** * UsageService - scans provider transcripts and returns priced usage buckets. * - * The scan reads the provider CLIs' own session files (Claude Code, Codex, and - * Grok Build) rather than T3 Code's orchestration projections, so usage covers - * turns driven outside T3 Code too. This is the approach `ccusage` takes. + * The scan reads native session files and databases, including work driven + * outside T3 Code. Cursor's local records provide only partial coverage. * - * Transcripts are append-only, so parsed records are memoised per file by + * JSONL transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm * scans only reparse files that changed, and a file that merely grew resumes * from its cached parse position so only the appended bytes are read. + * SQLite readers query live databases each scan so WAL writes remain visible. * * @module UsageService */ @@ -18,6 +18,7 @@ import { ClaudeSettings, CodexSettings, type ProviderInstanceConfig, + ProviderInstanceId, USAGE_CONTRACT_VERSION, type ServerSettings as ServerSettingsValue, type UsageProviderKind, @@ -27,10 +28,11 @@ import { type UsageSummaryInput, UsageReadError, } from "@t3tools/contracts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -46,7 +48,11 @@ import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { resolveAntigravityInstanceDirectories } from "../provider/antigravityAuthSupport.ts"; import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -144,12 +150,14 @@ export const layerTest = Layer.succeed( ); export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig; const settingsService = yield* ServerSettings.ServerSettingsService; const httpClient = yield* HttpClient.HttpClient; const hostEnvironment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; const fileCache: ScanCache = new Map(); const sourceCache = new Map(); @@ -225,7 +233,7 @@ export const make = Effect.gen(function* () { yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); }); @@ -263,10 +271,16 @@ export const make = Effect.gen(function* () { for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace // the legacy settings, just as they do in the provider registry. - const instances: Array> = - Object.values(settings.providerInstances).filter((instance) => instance.driver === driver); + const instances: Array< + Pick & { instanceId: ProviderInstanceId } + > = Object.entries(settings.providerInstances) + .filter(([, instance]) => instance.driver === driver) + .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); if (!Object.hasOwn(settings.providerInstances, driver)) { - instances.push({ config: settings.providers[driver] }); + instances.push({ + config: settings.providers[driver], + instanceId: ProviderInstanceId.make(driver), + }); } for (const instance of instances) { const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); @@ -275,12 +289,15 @@ export const make = Effect.gen(function* () { if (driver === "codex") { const decoded = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(decoded)) continue; - const config = decoded.value; + const codexConfig = decoded.value; const environmentHome = environment.CODEX_HOME?.trim(); const layout = yield* resolveCodexHomeLayout( - !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome - ? { ...config, homePath: environmentHome } - : config, + codexConfig.setupMode !== "managed" && + !codexConfig.homePath.trim() && + !codexConfig.shadowHomePath.trim() && + environmentHome + ? { ...codexConfig, homePath: environmentHome } + : codexConfig, ); home = layout.sharedHomePath; } else if (driver === "claudeAgent") { @@ -372,7 +389,7 @@ export const make = Effect.gen(function* () { cacheDirty = false; }), // A cache we cannot write is a slower next start, not a failed read. - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); }); @@ -446,6 +463,10 @@ export const make = Effect.gen(function* () { readonly provider: UsageProviderKind; readonly dir: string; readonly volumeId: string; + readonly hostId?: string; + readonly status?: UsageSource["status"]; + readonly message?: string; + readonly action?: UsageSource["action"]; /** Parsed records per file, or `null` when the directory does not exist. */ readonly files: | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] @@ -481,6 +502,173 @@ export const make = Effect.gen(function* () { } scanned.push({ provider, dir, volumeId, files: parsedFiles }); } + + const home = NodeOS.homedir(); + const envRoots = Effect.fnUntraced(function* (key: string, defaults: readonly string[]) { + const roots = hostEnvironment[key] + ?.split(",") + .map((value) => value.trim()) + .filter(Boolean); + const canonical = new Set(); + for (const root of roots?.length ? roots : defaults) { + const resolved = path.resolve(expandHomePath(root)); + canonical.add( + yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)), + ); + } + return [...canonical]; + }); + const dataHome = hostEnvironment["XDG_DATA_HOME"]?.trim(); + for (const dir of yield* envRoots("OPENCODE_DATA_DIR", [ + path.join( + dataHome && path.isAbsolute(dataHome) ? dataHome : path.join(home, ".local", "share"), + "opencode", + ), + ])) { + const result = yield* Effect.promise(() => readOpenCodeUsage(dir, windowStartMs)); + scanned.push({ + provider: "opencode", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: result.missing && !result.error ? null : result.files, + status: result.error ? "partial" : "ok", + ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), + }); + } + const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ + ...["antigravity", "antigravity-cli", "antigravity-ide", "antigravity-backup"].map((name) => + path.join(home, ".gemini", name), + ), + path.join(home, ".config", "antigravity"), + ]); + for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + if (instance.driver === "antigravity") { + const directories = yield* resolveAntigravityInstanceDirectories( + config.stateDir, + ProviderInstanceId.make(instanceId), + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Antigravity profile directory could not be resolved.", + cause, + }), + ), + ); + antigravityRoots.push(path.join(directories.profile, "antigravity-acp")); + } + } + const antigravityDirs = new Set(); + for (const root of antigravityRoots) { + const resolvedRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + const nested = path.join(resolvedRoot, "conversations"); + const dir = (yield* fileSystem + .exists(nested) + .pipe(Effect.catchCause(() => Effect.succeed(false)))) + ? nested + : resolvedRoot; + antigravityDirs.add(yield* fileSystem.realPath(dir).pipe(Effect.orElseSucceed(() => dir))); + } + const antigravity = yield* Effect.promise(() => + readAntigravityUsage([...antigravityDirs], windowStartMs), + ); + for (const dir of antigravityDirs) { + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const failed = antigravity.errors.some( + (error) => error === dir || error.startsWith(`${dir}${path.sep}`), + ); + scanned.push({ + provider: "antigravity", + dir, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(dir)), + files: !exists && !failed ? null : antigravity.files.filter((file) => file.root === dir), + status: failed ? "partial" : "ok", + ...(failed ? { message: "Some Antigravity history could not be read." } : {}), + }); + } + const cursorUserHome = + (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; + const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); + const cursorHome = + platform === "darwin" + ? path.join(cursorUserHome, "Library", "Application Support") + : platform === "win32" + ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") + : configHome && path.isAbsolute(configHome) + ? configHome + : path.join(cursorUserHome, ".config"); + const cursorAuthPath = + platform === "darwin" + ? path.join(cursorUserHome, ".cursor", "auth.json") + : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); + const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; + const loginUnavailable = + Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || + Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || + credentialStore === "memory"; + if ( + platform === "darwin" && + credentialStore !== "file" && + !loginUnavailable && + !settings.cursorKeychainUsageEnabled + ) { + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: "", + files: null, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }); + return scanned; + } + const cursorUntilMs = yield* Clock.currentTimeMillis; + const account = loginUnavailable + ? { + accountKey: null, + records: [], + missing: true, + error: "Cursor account history needs a Cursor CLI login on this server.", + } + : yield* Effect.promise(() => + readCursorAccountUsage( + platform === "darwin" && credentialStore !== "file" + ? { kind: "keychain" } + : cursorAuthPath, + windowStartMs, + cursorUntilMs, + ), + ); + // No saved login means there is no account source to report, not a setup error. + if (account.missing && account.error === null) return scanned; + if (account.accountKey !== null && account.error === null && !account.missing) { + // The same account includes CLI and desktop history from every machine. + // A stable remote fingerprint prevents connected environments counting it twice. + const source = `cursor-account:${account.accountKey}`; + scanned.push({ + provider: "cursor", + dir: source, + hostId: "cursor.com", + volumeId: account.accountKey, + files: [{ path: source, records: account.records }], + status: "ok", + }); + return scanned; + } + scanned.push({ + provider: "cursor", + dir: cursorAuthPath, + volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), + // Never combine a local fallback with another server's account-wide history. + files: null, + message: + account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", + }); return scanned; }); @@ -555,7 +743,16 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - for (const { provider, dir, volumeId, files } of scannedDirs) { + for (const { + provider, + dir, + volumeId, + files, + status, + message, + action, + hostId: sourceHostId, + } of scannedDirs) { const retainedFiles = [...(files ?? [])]; const livePaths = new Set(retainedFiles.map((file) => file.path)); // Cleanup may remove transcripts, but the usage we already saved still @@ -601,21 +798,23 @@ export const make = Effect.gen(function* () { } // Only sessions contributing in-window count; the mtime slack can // admit boundary files whose records fall outside the range. - if (aggregator.add(usageRecord) && record.sessionId.length > 0) { + if (aggregator.add(usageRecord, dir) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } } sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, + fingerprint: { hostId: sourceHostId ?? hostId, provider, resolvedHomePath: dir, volumeId }, // Clients exclude missing sources, so saved records remain an available source. - status: files === null && scannedFiles === 0 ? "missing" : "ok", + status: files === null && scannedFiles === 0 ? "missing" : (status ?? "ok"), scannedFiles, skippedFiles, malformedRecords: 0, distinctSessions: sessionIds.size, - message: files === null ? "No transcript directory on this environment." : null, + message: + message ?? (files === null ? "No transcript directory on this environment." : null), + ...(action ? { action } : {}), }); } @@ -650,6 +849,7 @@ export const make = Effect.gen(function* () { const scanKey = ( input: UsageSummaryInput, priceOverrides: ServerSettingsValue["usagePriceOverrides"], + cursorKeychainUsageEnabled: boolean, ): string => JSON.stringify([ input.timeZone, @@ -659,11 +859,12 @@ export const make = Effect.gen(function* () { input.sinceTime ?? null, input.untilTime ?? null, priceOverrides, + cursorKeychainUsageEnabled, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { const settings = yield* readSettings; - const key = scanKey(input, settings.usagePriceOverrides); + const key = scanKey(input, settings.usagePriceOverrides, settings.cursorKeychainUsageEnabled); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); diff --git a/apps/server/src/usage/antigravityUsageReader.ts b/apps/server/src/usage/antigravityUsageReader.ts new file mode 100644 index 000000000000..54c00b804b7a --- /dev/null +++ b/apps/server/src/usage/antigravityUsageReader.ts @@ -0,0 +1,374 @@ +// node:sqlite reads live conversation databases while Node fs discovers them. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; + +type FieldValue = number | bigint | Uint8Array; +type Fields = Map; + +/** Antigravity stores usage metadata as protobuf, independently of conversation text. */ +function fields(bytes: Uint8Array): Fields { + let offset = 0; + const result: Fields = new Map(); + const varint = () => { + let value = 0n; + for (let shift = 0n; shift < 70n; shift += 7n) { + const byte = bytes[offset++]; + if (byte === undefined || (shift === 63n && byte > 1)) { + throw new Error("Invalid Antigravity protobuf varint"); + } + value |= BigInt(byte & 127) << shift; + if (byte < 128) { + return value > BigInt(Number.MAX_SAFE_INTEGER) ? value : Number(value); + } + } + throw new Error("Invalid Antigravity protobuf varint"); + }; + while (offset < bytes.length) { + const tag = varint(); + if (typeof tag !== "number") throw new Error("Invalid protobuf field"); + const number = Math.floor(tag / 8); + const wire = tag % 8; + if (number === 0) throw new Error("Invalid protobuf field"); + let value: FieldValue; + if (wire === 0) { + value = varint(); + } else if (wire === 1 || wire === 5 || wire === 2) { + const length = wire === 2 ? varint() : wire === 1 ? 8 : 4; + if (typeof length !== "number") throw new Error("Invalid protobuf field length"); + if (length > bytes.length - offset) throw new Error("Truncated protobuf field"); + value = bytes.subarray(offset, offset + length); + offset += length; + if (wire !== 2) continue; + } else { + throw new Error("Unsupported protobuf wire type"); + } + const entries = result.get(number) ?? []; + entries.push(value); + result.set(number, entries); + } + return result; +} + +const numberAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return typeof entry === "number" ? entry : 0; +}; +const bytesAt = (value: Fields, key: number) => { + const entry = value.get(key)?.[0]; + return entry instanceof Uint8Array ? entry : undefined; +}; +const nested = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? new Map() : fields(bytes); +}; +const textAt = (value: Fields, key: number) => { + const bytes = bytesAt(value, key); + return bytes === undefined ? "" : new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); +}; +const timestamp = (value: Fields) => { + const seconds = numberAt(value, 1); + return seconds > 0 ? seconds * 1000 + Math.floor(numberAt(value, 2) / 1_000_000) : null; +}; + +const MODEL_IDS: Record = { + 246: "gemini-2.5-pro", + 312: "gemini-2.5-flash", + 313: "gemini-2.5-flash-thinking", + 329: "gemini-2.5-flash-thinking", + 330: "gemini-2.5-flash-lite", + 281: "claude-sonnet-4", + 282: "claude-sonnet-4", + 290: "claude-opus-4", + 291: "claude-opus-4", + 333: "claude-sonnet-4-5", + 334: "claude-sonnet-4-5", + 340: "claude-haiku-4-5", + 341: "claude-haiku-4-5", + 1026: "claude-opus-4-6", + 1035: "claude-sonnet-4-6", + 1016: "gemini-3.1-pro", + 1036: "gemini-3.1-pro", + 1037: "gemini-3.1-pro", + 1018: "gemini-3-flash-preview", + 1084: "gemini-3-flash-preview", + 1047: "gemini-3-flash-preview", +}; + +function modelName(name: string, id: number): string { + if (name) { + const normalized = name + .toLowerCase() + .replace(/\s*\([^)]*\)\s*$/, "") + .replaceAll(" ", "-"); + if (normalized.startsWith("claude-")) { + return normalized + .replace(/^claude-(4(?:\.\d+)?)-(sonnet|opus|haiku)/, "claude-$2-$1") + .replaceAll(".", "-"); + } + return normalized; + } + return MODEL_IDS[id] ?? (id > 0 ? `antigravity-model-${id}` : ""); +} + +interface Metadata { + model: string; + timestampMs: number | null; + usages: Fields[]; +} + +function metadata(bytes: Uint8Array, step: boolean): Metadata { + const root = fields(bytes); + if (!step && bytesAt(root, 1) === undefined) { + throw new Error("Missing Antigravity generation metadata"); + } + const data = step ? root : nested(root, 1); + const model = step ? nested(data, 24) : data; + const usage = bytesAt(data, step ? 9 : 4); + const usages = usage === undefined ? [] : [fields(usage)]; + for (const retry of data.get(step ? 28 : 17) ?? []) { + if (!(retry instanceof Uint8Array)) throw new Error("Invalid retry metadata"); + const retryUsage = bytesAt(fields(retry), 2); + if (retryUsage !== undefined) usages.push(fields(retryUsage)); + } + return { + model: modelName( + textAt(model, step ? 12 : 19) || textAt(model, step ? 8 : 21), + numberAt(model, step ? 1 : 3), + ), + timestampMs: step + ? (timestamp(nested(data, 8)) ?? timestamp(nested(data, 1))) + : timestamp(nested(nested(data, 9), 4)), + usages, + }; +} + +function blob(value: unknown): Uint8Array { + if (!(value instanceof Uint8Array)) throw new Error("Invalid Antigravity metadata blob"); + return value; +} + +interface UsageCandidate { + record: UsageRecord; + keys: readonly string[]; + timestampQuality: number; +} + +async function readDatabase(path: string, fallbackTimestamp: number): Promise { + const db = new NodeSqlite.DatabaseSync(path, { readOnly: true }); + try { + db.exec("PRAGMA busy_timeout = 100; BEGIN"); + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("gen_metadata") && !tables.has("steps")) { + throw new Error("Missing Antigravity usage tables"); + } + const readMetadata = async (query: string, column: string, step: boolean) => { + const entries: Array<{ idx: number; entry: Metadata }> = []; + for (const row of db.prepare(query).iterate()) { + if (typeof row.idx !== "number") throw new Error("Invalid Antigravity metadata index"); + entries.push({ idx: row.idx, entry: metadata(blob(row[column]), step) }); + if (entries.length % 256 === 0) await NodeTimersPromises.setImmediate(); + } + return entries; + }; + const generations = tables.has("gen_metadata") + ? await readMetadata("SELECT idx, data FROM gen_metadata ORDER BY idx", "data", false) + : []; + let trajectoryTimestamp: number | null = null; + if (tables.has("trajectory_metadata_blob")) { + for (const row of db.prepare("SELECT data FROM trajectory_metadata_blob").iterate()) { + trajectoryTimestamp ??= timestamp(nested(fields(blob(row.data)), 2)); + } + } + const steps = tables.has("steps") + ? await readMetadata( + "SELECT idx, metadata FROM steps WHERE metadata IS NOT NULL ORDER BY idx", + "metadata", + true, + ) + : []; + const sessionId = NodePath.basename(path, ".db"); + const records: UsageCandidate[] = []; + const generationModels = new Map(generations.map(({ idx, entry }) => [idx, entry.model])); + for (const [source, entries] of [ + ["step", steps], + ["generation", generations], + ] as const) { + for (const [index, { idx, entry }] of entries.entries()) { + for (const [usageIndex, usage] of entry.usages.entries()) { + const outputTokens = Math.max( + numberAt(usage, 3), + numberAt(usage, 9) + numberAt(usage, 10), + ); + const totals = { + uncachedInputTokens: numberAt(usage, 2), + cachedInputTokens: numberAt(usage, 5), + cacheCreationTokens: numberAt(usage, 4), + outputTokens, + reasoningTokens: Math.min(outputTokens, numberAt(usage, 9)), + }; + if ( + totals.uncachedInputTokens + + totals.cachedInputTokens + + totals.cacheCreationTokens + + outputTokens === + 0 + ) + continue; + const keys = ([11, 12, 7] as const).flatMap((key) => { + const id = textAt(usage, key); + return id ? [`antigravity:${key}:${id}`] : []; + }); + const record: UsageRecord = { + provider: "antigravity", + sessionId, + timestampMs: entry.timestampMs ?? trajectoryTimestamp ?? fallbackTimestamp, + model: + MODEL_IDS[numberAt(usage, 1)] || + entry.model || + (source === "step" ? generationModels.get(idx) : "") || + modelName("", numberAt(usage, 1)) || + "antigravity-unknown", + totals, + reportedCostUsd: null, + fast: false, + dedupeKey: keys[0] ?? `antigravity:${sessionId}:${source}:${index}:${usageIndex}`, + }; + records.push({ + record, + keys, + timestampQuality: entry.timestampMs !== null ? 2 : trajectoryTimestamp !== null ? 1 : 0, + }); + } + } + } + return records; + } finally { + db.close(); + } +} + +/** Reads and merges aliases across every configured Antigravity store before date filtering. */ +export async function readAntigravityUsage( + conversationsDirectories: string | readonly string[], + sinceMs: number, +) { + const roots = + typeof conversationsDirectories === "string" + ? [conversationsDirectories] + : conversationsDirectories; + const files: Array<{ root: string; path: string; records: UsageRecord[] }> = []; + const errors: string[] = []; + const identities = new Map(); + const groups: Array< + UsageCandidate & { parent: number; size: number; owner: number; fileIndex: number } + > = []; + const find = (index: number): number => { + let root = index; + while (groups[root]!.parent !== root) root = groups[root]!.parent; + while (index !== root) { + const parent = groups[index]!.parent; + groups[index]!.parent = root; + index = parent; + } + return root; + }; + const merge = (left: number, right: number): number => { + let a = find(left); + let b = find(right); + if (a === b) return a; + if (groups[a]!.size < groups[b]!.size) [a, b] = [b, a]; + const target = groups[a]!; + const source = groups[b]!; + const first = target.owner < source.owner ? target : source; + const bestTime = + source.timestampQuality > target.timestampQuality || + (source.timestampQuality === target.timestampQuality && + source.record.timestampMs < target.record.timestampMs) + ? source + : target; + const x = target.record.totals; + const y = source.record.totals; + target.record = { + ...first.record, + model: + first.record.model === "antigravity-unknown" + ? first === target + ? source.record.model + : target.record.model + : first.record.model, + timestampMs: bestTime.record.timestampMs, + totals: { + uncachedInputTokens: Math.max(x.uncachedInputTokens, y.uncachedInputTokens), + cachedInputTokens: Math.max(x.cachedInputTokens, y.cachedInputTokens), + cacheCreationTokens: Math.max(x.cacheCreationTokens, y.cacheCreationTokens), + outputTokens: Math.max(x.outputTokens, y.outputTokens), + reasoningTokens: Math.max(x.reasoningTokens, y.reasoningTokens), + }, + }; + target.timestampQuality = bestTime.timestampQuality; + target.owner = first.owner; + target.fileIndex = first.fileIndex; + target.size += source.size; + source.parent = a; + return a; + }; + const append = (candidate: UsageCandidate, fileIndex: number) => { + const index = groups.length; + groups.push({ ...candidate, parent: index, size: 1, owner: index, fileIndex }); + for (const key of candidate.keys) { + const existing = identities.get(key); + if (existing !== undefined) merge(index, existing); + identities.set(key, index); + } + }; + const visited = new Set(); + const walk = async (directory: string, root: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") errors.push(directory); + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path, root); + } else if (entry.isFile() && entry.name.endsWith(".db")) { + try { + const canonical = await NodeFSP.realpath(path); + if (visited.has(canonical)) continue; + visited.add(canonical); + const stat = await NodeFSP.stat(path); + const candidates = await readDatabase(path, stat.mtimeMs); + const fileIndex = files.length; + files.push({ root, path, records: [] }); + for (const [index, candidate] of candidates.entries()) { + append(candidate, fileIndex); + if (index % 256 === 255) await NodeTimersPromises.setImmediate(); + } + } catch { + errors.push(path); + } + } + } + }; + for (const root of roots) await walk(root, root); + for (const [index, group] of groups.entries()) { + if (group.parent === index && group.record.timestampMs >= sinceMs) { + files[group.fileIndex]!.records.push(group.record); + } + } + return { files, errors }; +} diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts new file mode 100644 index 000000000000..4bfa647c0aff --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -0,0 +1,22 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError } from "../provider/cursorCredentialStore.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; + +describe("readCursorAccountUsage", () => { + it("asks for Keychain approval when the prompt goes unanswered", async () => { + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1, + () => Promise.reject(new Error("no network expected")), + () => Promise.reject(new CursorKeychainTimeoutError()), + ); + assert.deepStrictEqual(result, { + accountKey: null, + records: [], + missing: false, + error: "Allow Keychain access on the Mac running T3 Code, then refresh.", + }); + }); +}); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts new file mode 100644 index 000000000000..573506d818a1 --- /dev/null +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -0,0 +1,272 @@ +// Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeCrypto from "node:crypto"; +import * as NodeTimersPromises from "node:timers/promises"; + +import type { UsageRecord } from "./usageTranscripts.ts"; +import { + CursorKeychainTimeoutError, + readMacCursorAccessToken, +} from "../provider/cursorCredentialStore.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +/** + * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, + * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. + * Grok resolves through xAI's first-party entry, which has no bare alias. + */ +export function cursorRateModel(model: string): string { + const base = model + .replace(/^cursor-/, "") + .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); + return base.startsWith("grok-") ? `xai/${base}` : base; +} + +export interface CursorAccountUsageReadResult { + readonly accountKey: string | null; + readonly records: readonly UsageRecord[]; + readonly missing: boolean; + readonly error: string | null; +} + +const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** Find the longest exact suffix/prefix overlap in linear time. */ +function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { + const sequence = [...current, "", ...previous]; + const lengths = Array.from({ length: sequence.length }, () => 0); + for (let index = 1; index < sequence.length; index++) { + let length = lengths[index - 1]!; + while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; + if (sequence[index] === sequence[length]) length++; + lengths[index] = length; + } + return lengths.at(-1) ?? 0; +} + +/** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ +export async function readCursorAccountUsage( + credentialSource: string | { readonly kind: "keychain" }, + sinceMs: number, + endDate: number, + request: (url: string, init: RequestInit) => Promise = globalThis.fetch, + keychainToken: () => Promise = readMacCursorAccessToken, +): Promise { + let accessToken: unknown; + try { + accessToken = + typeof credentialSource === "string" + ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken + : await keychainToken(); + } catch (cause) { + const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; + return { + accountKey: null, + records: [], + missing, + error: missing + ? null + : typeof credentialSource === "string" + ? "Cursor credentials could not be read." + : cause instanceof CursorKeychainTimeoutError + ? "Allow Keychain access on the Mac running T3 Code, then refresh." + : "Cursor Keychain credentials could not be read.", + }; + } + if (typeof accessToken !== "string" || !accessToken) { + return { + accountKey: null, + records: [], + missing: true, + error: + typeof credentialSource === "string" + ? null + : "Cursor account history needs a macOS Keychain CLI login on this server.", + }; + } + let accountKey: string | null = null; + try { + const payload = accessToken.split(".")[1]; + const subject = object( + JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), + ).sub; + if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); + const userId = subject.split("|").at(-1); + if (!userId) throw new Error("Invalid authentication"); + accountKey = accountHash(subject); + if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) + throw new Error("Invalid date window"); + const deadline = AbortSignal.timeout(60_000); + const records: UsageRecord[] = []; + const occurrences = new Map(); + const pages: unknown[][] = []; + let completed = false; + const pageSize = 1000; + let total: number | undefined; + for (let page = 1; ; page++) { + // A count can include overlapping page boundaries. Allow room to + // reconcile them without imposing a fixed account-size limit. + if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { + throw new Error("Account usage page limit exceeded"); + } + const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { + method: "POST", + redirect: "error", + signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), + headers: { + "Content-Type": "application/json", + Origin: "https://cursor.com", + Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, + }, + body: JSON.stringify({ + page, + pageSize, + startDate: String(sinceMs), + endDate: String(endDate), + }), + }); + if (response.status === 401 || response.status === 403) { + return { + accountKey, + records: [], + missing: false, + error: "Sign in to Cursor again to read account usage.", + }; + } + if (!response.ok) throw new Error("Account usage request failed"); + const parsed: unknown = await response.json(); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid account usage page"); + } + const body = object(parsed); + const keys = Object.keys(body); + if ("error" in body || "message" in body || "code" in body) + throw new Error("Account usage error response"); + const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; + const events = + keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") + ? [] + : body.usageEventsDisplay; + if ( + (count !== undefined && + (typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 0 || + (total !== undefined && count !== total))) || + !Array.isArray(events) || + events.length > pageSize || + (count === undefined && !Array.isArray(body.usageEventsDisplay)) + ) { + throw new Error("Inconsistent account usage page"); + } + if (typeof count === "number") total = count; + pages.push(events); + if (events.length < pageSize) { + completed = true; + break; + } + } + if (!completed) throw new Error("Account usage page limit exceeded"); + const rawCount = pages.reduce((sum, page) => sum + page.length, 0); + if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); + let removalsRemaining = total === undefined ? 0 : rawCount - total; + let previousKeys: string[] = []; + for (const events of pages) { + const eventKeys = + removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; + const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); + removalsRemaining -= removalCount; + previousKeys = eventKeys; + for (const raw of events.slice(removalCount)) { + const event = object(raw); + const usage = object(event.tokenUsage); + if (event.tokenUsage === undefined || event.tokenUsage === null) continue; + for (const key of [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "totalCents", + ]) { + const value = usage[key]; + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value < 0) + ) { + throw new Error("Invalid account usage totals"); + } + } + const timestampMs = + typeof event.timestamp === "string" && event.timestamp.trim() !== "" + ? Number(event.timestamp) + : event.timestamp; + if ( + typeof timestampMs !== "number" || + !Number.isFinite(timestampMs) || + typeof event.model !== "string" || + !event.model + ) + throw new Error("Invalid account usage event"); + if (timestampMs < sinceMs || timestampMs > endDate) continue; + const totals = { + uncachedInputTokens: tokens(usage.inputTokens), + cachedInputTokens: tokens(usage.cacheReadTokens), + cacheCreationTokens: tokens(usage.cacheWriteTokens), + outputTokens: tokens(usage.outputTokens), + reasoningTokens: 0, + }; + const reportedCostUsd = + typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; + const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; + // No event ID is provided. Preserve identical billed rows with an occurrence index. + const key = accountHash( + JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), + ); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + records.push({ + provider: "cursor", + timestampMs, + model: event.model, + rateModel: cursorRateModel(event.model), + sessionId, + totals, + reportedCostUsd, + fast: false, + dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, + }); + } + await NodeTimersPromises.setImmediate(); + } + if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); + return { accountKey, records, missing: false, error: null }; + } catch { + return { + accountKey, + records: [], + missing: false, + error: "Cursor account usage could not be read.", + }; + } +} diff --git a/apps/server/src/usage/opencodeUsageReader.ts b/apps/server/src/usage/opencodeUsageReader.ts new file mode 100644 index 000000000000..45d6ef33a584 --- /dev/null +++ b/apps/server/src/usage/opencodeUsageReader.ts @@ -0,0 +1,188 @@ +// node:sqlite reads live OpenCode databases; Node fs walks legacy JSON history. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; +import * as NodeTimersPromises from "node:timers/promises"; + +import { totalTokens, type UsageRecord } from "./usageTranscripts.ts"; + +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function tokens(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** OpenCode stores uncached input and reasoning separately from input/output. */ +function parseOpenCodeMessage( + source: string, + fallback: { + readonly id?: string; + readonly sessionId?: string; + readonly timestampMs?: number; + } = {}, +): UsageRecord | null { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + const message = object(parsed); + if (message.role !== undefined && message.role !== "assistant") return null; + const usage = object(message.tokens); + const cache = object(usage.cache); + const modelReference = object(message.model); + const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); + const timestampMs = object(message.time).created ?? fallback.timestampMs; + if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null; + const reasoningTokens = tokens(usage.reasoning); + const totals = { + uncachedInputTokens: tokens(usage.input), + cachedInputTokens: tokens(cache.read), + cacheCreationTokens: tokens(cache.write), + outputTokens: tokens(usage.output) + reasoningTokens, + reasoningTokens, + }; + if (totalTokens(totals) === 0) return null; + const id = fallback.id || text(message.id); + const cost = message.cost; + return { + provider: "opencode", + timestampMs, + model, + sessionId: fallback.sessionId || text(message.sessionID), + totals, + // OpenCode writes zero for models without a known rate, including paid + // subscription models. Let the shared price table estimate those records. + reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null, + fast: false, + dedupeKey: id ? `opencode:${id}` : null, + }; +} + +export interface OpenCodeUsageReadResult { + readonly files: readonly { readonly path: string; readonly records: readonly UsageRecord[] }[]; + readonly missing: boolean; + readonly error: boolean; +} + +/** Reads current SQLite and pre-migration JSON stores without modifying either. */ +export async function readOpenCodeUsage( + root: string, + sinceMs: number, +): Promise { + const files: { path: string; records: UsageRecord[] }[] = []; + const seen = new Set(); + let found = false; + let error = false; + const append = (records: UsageRecord[], record: UsageRecord | null) => { + if (record === null || record.timestampMs < sinceMs) return; + if (record.dedupeKey !== null) { + if (seen.has(record.dedupeKey)) return; + seen.add(record.dedupeKey); + } + records.push(record); + }; + + let databases: string[] = []; + try { + databases = (await NodeFSP.readdir(root, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^opencode(?:-[a-zA-Z0-9_-]+)?\.db$/.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => (a === "opencode.db" ? -1 : b === "opencode.db" ? 1 : a.localeCompare(b))); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + for (const name of databases) { + found = true; + const file = { path: NodePath.join(root, name), records: [] as UsageRecord[] }; + files.push(file); + let database: NodeSqlite.DatabaseSync | undefined; + try { + database = new NodeSqlite.DatabaseSync(NodePath.join(root, name), { readOnly: true }); + // A busy live provider should fail this source promptly rather than + // stalling the server while SQLite waits for its writer. + database.exec("PRAGMA busy_timeout = 100"); + const tables = new Set( + database + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => row.name), + ); + if (!tables.has("message") && !tables.has("session_message")) error = true; + for (const table of ["message", "session_message"] as const) { + if (!tables.has(table)) continue; + const columns = new Set( + database + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => row.name), + ); + const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const predicates = table === "session_message" ? ["type = 'assistant'"] : []; + if (timestamp !== "NULL") predicates.push("time_created >= ?"); + const where = predicates.length > 0 ? ` WHERE ${predicates.join(" AND ")}` : ""; + const statement = database.prepare( + `SELECT id, session_id, data, ${timestamp} AS created FROM ${table}${where}`, + ); + let count = 0; + for (const row of statement.iterate(...(timestamp === "NULL" ? [] : [sinceMs]))) { + append( + file.records, + parseOpenCodeMessage(text(row.data), { + id: text(row.id), + sessionId: text(row.session_id), + ...(typeof row.created === "number" ? { timestampMs: row.created } : {}), + }), + ); + if (++count % 256 === 0) await NodeTimersPromises.setImmediate(); + } + } + } catch { + error = true; + } finally { + database?.close(); + } + } + + // Do not follow symlinks, including cycles. Database records win over their + // old JSON copies when OpenCode has migrated a store in place. + const directories = [NodePath.join(root, "storage", "message")]; + while (directories.length > 0) { + const directory = directories.pop()!; + try { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + found = true; + const id = entry.name.slice(0, -5); + if (seen.has(`opencode:${id}`)) continue; + const file = { path, records: [] as UsageRecord[] }; + files.push(file); + try { + append( + file.records, + parseOpenCodeMessage(await NodeFSP.readFile(path, "utf8"), { id }), + ); + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + } + } catch (cause) { + if (object(cause).code !== "ENOENT") error = true; + } + } + return { files, missing: !found && !error, error }; +} diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 8da4e920ac06..75435de08ff7 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -12,6 +12,7 @@ const rates: RateTable = new Map([ outputCostPerToken: 5e-5, cacheReadCostPerToken: 1e-6, cacheCreationCostPerToken: 1.25e-5, + fastMultiplier: 1, }, ], ]); @@ -31,6 +32,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, + fast: false, dedupeKey: null, ...overrides, }; diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 2ad3893ad4e6..92abfef74108 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -112,7 +112,7 @@ export class UsageAggregator { * can derive per-window facts (distinct sessions, for one) from the records * that landed rather than everything the mtime prefilter happened to admit. */ - add(record: UsageRecord): boolean { + add(record: UsageRecord, sourcePath?: string): boolean { if (record.dedupeKey !== null) { if (this.#seen.has(record.dedupeKey)) { this.#duplicatesDropped += 1; @@ -146,7 +146,7 @@ export class UsageAggregator { this.#hourlyWindow.sinceTimeMs + Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS) * HOUR_MS, ).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}`; + const key = `${day}\u0000${hourStart}\u0000${record.provider}\u0000${record.model}\u0000${sourcePath ?? ""}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -161,20 +161,13 @@ export class UsageAggregator { this.#buckets.set(key, bucket); } - const priced = priceUsage( - this.#options.rates, - record.model, - record.totals, - record.reportedCostUsd, - this.#options.priceOverrides, - ); + const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; bucket.cacheSavingsUsd += cacheSavingsUsd( this.#options.rates, - record.model, - record.totals, + record, this.#options.priceOverrides, ); bucket.records += 1; @@ -187,12 +180,14 @@ export class UsageAggregator { finish(): AggregateResult { const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = ""] = key.split("\u0000"); + const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = + key.split("\u0000"); buckets.push({ day: day as UsageDay, ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, + ...(sourcePath === "" ? {} : { sourcePath }), totals: bucket.totals, costUsd: bucket.costUsd, cacheSavingsUsd: bucket.cacheSavingsUsd, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 713d860999cb..db4f68c2cb42 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; +import { cursorRateModel } from "./cursorUsageReader.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -22,6 +23,12 @@ describe("usage pricing", () => { outputTokens: 1_000_000, reasoningTokens: 500_000, }; + const record = (model: string, reportedCostUsd: number | null = null, fast = false) => ({ + model, + totals, + reportedCostUsd, + fast, + }); it("uses custom token rates ahead of public and provider-reported costs", () => { const table = parseRateTable({ "example-model": rate(1) }); @@ -35,12 +42,32 @@ describe("usage pricing", () => { }); for (const reportedCostUsd of [null, 99]) { - expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({ + expect(priceUsage(table, record("example-model", reportedCostUsd), overrides)).toEqual({ costUsd: 13.5, costSource: "modelPriced", }); } - expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5); + expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); + }); + + it("prices Cursor cache savings at the base model rate", () => { + const table = parseRateTable({ + "claude-fable-5-1": rate(10e-6, 1e-6), + "xai/grok-4.7": rate(2e-6, 0.5e-6), + "openrouter/x-ai/grok-4.7": rate(3e-6, 0.5e-6), + }); + const cursorRecord = (model: string) => ({ + ...record(model, 0.25), + rateModel: cursorRateModel(model), + }); + + expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); + expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); + expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + costUsd: 0.25, + costSource: "providerReported", + }); }); it("prices unknown models offline and uses input prices for omitted cache rates", () => { @@ -49,11 +76,11 @@ describe("usage pricing", () => { "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, }); - expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({ + expect(priceUsage(table, record("example-model"), overrides)).toEqual({ costUsd: 14, costSource: "modelPriced", }); - expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0); + expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(0); }); it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { @@ -64,7 +91,7 @@ describe("usage pricing", () => { outputCostPerMillionTokens: 0, }, }); - expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({ + expect(priceUsage(table, record(" vendor/example-model[1m] ", 99), overrides)).toEqual({ costUsd: 0, costSource: "modelPriced", }); @@ -74,14 +101,36 @@ describe("usage pricing", () => { "vendor/Example-model[1m]", "other/example-model[1m]", ]) { - expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced"); - expect(priceUsage(table, model, totals, 99, overrides)).toEqual({ + expect(priceUsage(table, record(model), overrides).costSource).toBe("unpriced"); + expect(priceUsage(table, record(model, 99), overrides)).toEqual({ costUsd: 99, costSource: "providerReported", }); } }); + it("prices fast-mode requests at the model's published fast multiple", () => { + const table = parseRateTable({ + "claude-opus-5-5": { ...rate(4e-6, 2e-7), provider_specific_entry: { fast: 2, us: 1.1 } }, + "claude-fable-5-1": { ...rate(1e-5, 2.5e-7), provider_specific_entry: { us: 1.1 } }, + }); + const overrides = createOverrideRateTable({ + "claude-opus-5-5": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 20 }, + }); + const cost = (model: string, fast: boolean, custom?: typeof overrides) => + priceUsage(table, record(model, null, fast), custom).costUsd; + + expect(cost("claude-opus-5-5", true)).toBeCloseTo(2 * cost("claude-opus-5-5", false)); + expect(cacheSavingsUsd(table, record("claude-opus-5-5", null, true))).toBeCloseTo( + 2 * cacheSavingsUsd(table, record("claude-opus-5-5")), + ); + // No published fast tier, and custom prices, both stay at the standard rate. + expect(cost("claude-fable-5-1", true)).toBe(cost("claude-fable-5-1", false)); + expect(cost("claude-opus-5-5", true, overrides)).toBe( + cost("claude-opus-5-5", false, overrides), + ); + }); + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 6c94be424827..78f6cf2c5cd9 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,11 +7,9 @@ * * @module usagePricing */ -import type { - UsageCostSource, - UsageModelPriceOverride, - UsageTokenTotals, -} from "@t3tools/contracts"; +import type { UsageCostSource, UsageModelPriceOverride } from "@t3tools/contracts"; + +import type { UsageRecord } from "./usageTranscripts.ts"; /** * The subset of a LiteLLM entry we price against. All values are USD per token. @@ -26,11 +24,19 @@ export interface ModelRate { readonly outputCostPerToken: number; readonly cacheReadCostPerToken: number; readonly cacheCreationCostPerToken: number; + /** + * Multiple of the rates above billed for a fast-mode request, from LiteLLM's + * `provider_specific_entry.fast`. `1` when the model publishes no fast tier. + */ + readonly fastMultiplier: number; } export type RateTable = ReadonlyMap; -/** Custom IDs keep their case, provider prefix, and variant suffix. */ +/** + * Custom IDs keep their case, provider prefix, and variant suffix. Custom rates + * apply as entered, fast-mode requests included. + */ export function createOverrideRateTable( overrides: Readonly>, ): RateTable { @@ -44,6 +50,7 @@ export function createOverrideRateTable( (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, cacheCreationCostPerToken: (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + fastMultiplier: 1, }, ]), ); @@ -55,12 +62,21 @@ interface LiteLlmEntry { readonly output_cost_per_token?: unknown; readonly cache_read_input_token_cost?: unknown; readonly cache_creation_input_token_cost?: unknown; + readonly provider_specific_entry?: unknown; } function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** Reads `provider_specific_entry.fast`, e.g. `2` for Claude Opus 5.5. */ +function fastMultiplier(entry: LiteLlmEntry): number { + const specific = entry.provider_specific_entry; + if (typeof specific !== "object" || specific === null) return 1; + const fast = finiteNumber((specific as Record)["fast"]); + return fast !== null && fast > 0 ? fast : 1; +} + /** * Projects the LiteLLM document into a rate table. * @@ -92,6 +108,7 @@ export function parseRateTable(document: unknown): RateTable { // input rather than as free. cacheReadCostPerToken: finiteNumber(entry.cache_read_input_token_cost) ?? input, cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, + fastMultiplier: fastMultiplier(entry), }); } @@ -119,7 +136,8 @@ function sameRate(a: ModelRate, b: ModelRate): boolean { a.inputCostPerToken === b.inputCostPerToken && a.outputCostPerToken === b.outputCostPerToken && a.cacheReadCostPerToken === b.cacheReadCostPerToken && - a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken && + a.fastMultiplier === b.fastMultiplier ); } @@ -165,39 +183,47 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { return table.get(key) ?? null; } +/** The parts of a transcript record that decide its price. */ +export type PricedRecord = Pick< + UsageRecord, + "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" +>; + export interface PricedUsage { readonly costUsd: number; readonly costSource: UsageCostSource; } /** - * Prices a bucket's tokens. + * Prices one record's tokens. * * `reasoningTokens` is intentionally not charged separately: it is already * counted inside `outputTokens`. */ export function priceUsage( table: RateTable, - model: string, - totals: UsageTokenTotals, - reportedCostUsd: number | null, + record: PricedRecord, overrides?: RateTable, ): PricedUsage { + const { model, totals, reportedCostUsd } = record; const override = overrides?.get(model.trim()); if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = override ?? lookupRate(table, model); + const rate = override ?? lookupRate(table, record.rateModel ?? model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; - const costUsd = + const standardCostUsd = totals.uncachedInputTokens * rate.inputCostPerToken + totals.cachedInputTokens * rate.cacheReadCostPerToken + totals.cacheCreationTokens * rate.cacheCreationCostPerToken + totals.outputTokens * rate.outputCostPerToken; - return { costUsd, costSource: "modelPriced" }; + return { + costUsd: standardCostUsd * (record.fast ? rate.fastMultiplier : 1), + costSource: "modelPriced", + }; } /** @@ -206,11 +232,15 @@ export function priceUsage( */ export function cacheSavingsUsd( table: RateTable, - model: string, - totals: UsageTokenTotals, + record: PricedRecord, overrides?: RateTable, ): number { - const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); + const rate = + overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; - return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); + return ( + record.totals.cachedInputTokens * + (rate.inputCostPerToken - rate.cacheReadCostPerToken) * + (record.fast ? rate.fastMultiplier : 1) + ); } diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index cc1bdbcc1626..6455b1eb7770 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -24,6 +24,7 @@ function record(overrides: Partial = {}): UsageRecord { reasoningTokens: 0, }, reportedCostUsd: null, + fast: false, dedupeKey: "msg_1:", ...overrides, }; @@ -57,7 +58,11 @@ function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]) describe("scan cache round trip", () => { it("restores records unchanged", () => { const original = cacheWith([ - ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], + [ + "/a.jsonl", + 100, + [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", fast: true })], + ], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); original.set("/grok.jsonl", { @@ -123,9 +128,20 @@ describe("scan cache round trip", () => { expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); }); + it("drops an entry whose fast flag is not 0 or 1", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record({ fast: true })]]])); + const row = encoded.files["/a.jsonl"]!.r[0]!; + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, r: [[...row.slice(0, 10), true]] } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + it("rejects a document from the previous cache version", () => { const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); - const previous = { ...encoded, version: 2 }; + const previous = { ...encoded, version: 3 }; expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 71ef25051eb6..79cca5cff8e2 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -23,7 +23,8 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -const USAGE_SCAN_CACHE_VERSION = 3 as const; +// v4: records carry Claude fast mode, which v3 rows never captured. +const USAGE_SCAN_CACHE_VERSION = 4 as const; export interface CachedFile { readonly size: number; @@ -58,6 +59,7 @@ type SerializedRecord = readonly [ reasoningTokens: number, dedupeKey: string | null, reportedCostUsd: number | null, + fast: 0 | 1, ]; interface SerializedFile { @@ -109,6 +111,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { record.totals.reasoningTokens, record.dedupeKey, record.reportedCostUsd, + record.fast ? 1 : 0, ]; const files: Record = {}; @@ -165,7 +168,7 @@ export function decodeScanCache(document: unknown): ScanCache { ): UsageRecord[] | null => { const records: UsageRecord[] = []; for (const row of rows) { - if (!isRecordArray(row) || row.length < 10) return null; + if (!isRecordArray(row) || row.length < 11) return null; const [ timestampMs, modelIndex, @@ -177,6 +180,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoning, dedupeKey, reportedCostUsd, + fast, ] = row as SerializedRecord; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; @@ -188,7 +192,8 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || !Number.isFinite(output) || - !Number.isFinite(reasoning) + !Number.isFinite(reasoning) || + (fast !== 0 && fast !== 1) ) { return null; } @@ -206,6 +211,7 @@ export function decodeScanCache(document: unknown): ScanCache { reasoningTokens: reasoning, }, reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null, + fast: fast === 1, dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index 5feb68b2ff58..6c95a36df19b 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -4,13 +4,40 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeSqlite from "node:sqlite"; import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; import { readTranscriptRecords } from "./usageTranscriptReader.ts"; +import { readOpenCodeUsage } from "./opencodeUsageReader.ts"; +import { readCursorAccountUsage } from "./cursorUsageReader.ts"; +import { readAntigravityUsage } from "./antigravityUsageReader.ts"; let dir: string; +function protoNumber(field: number, value: number): number[] { + const varint = (number: number) => { + const bytes: number[] = []; + do { + const byte = number % 128; + number = Math.floor(number / 128); + bytes.push(byte + (number > 0 ? 128 : 0)); + } while (number > 0); + return bytes; + }; + return [...varint(field * 8), ...varint(value)]; +} + +function protoBytes(field: number, bytes: readonly number[]): number[] { + const encoded = protoNumber(field, bytes.length); + encoded[0] = encoded[0]! + 2; + return [...encoded, ...bytes]; +} + +function protoText(field: number, value: string): number[] { + return protoBytes(field, [...Buffer.from(value)]); +} + beforeEach(async () => { dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); }); @@ -208,3 +235,530 @@ describe("readTranscriptRecords resume", () => { assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); }); }); + +describe("SQLite usage readers", () => { + it("reads Cursor account history with the default macOS Keychain login", async () => { + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + let keychainReads = 0; + const result = await readCursorAccountUsage( + { kind: "keychain" }, + 0, + 1781000000000, + async (_url, init) => { + assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); + return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); + }, + async () => { + keychainReads++; + return accessToken; + }, + ); + assert.strictEqual(keychainReads, 1); + assert.isNull(result.error); + assert.isFalse(result.missing); + assert.isNotNull(result.accountKey); + }); + + it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const pages: number[] = []; + const signals: AbortSignal[] = []; + const request = async (url: string, init: RequestInit) => { + assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); + assert.strictEqual(init?.redirect, "error"); + const headers = new Headers(init?.headers); + assert.strictEqual(headers.get("origin"), "https://cursor.com"); + assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); + const body = JSON.parse(String(init?.body)); + pages.push(body.page); + if (init.signal) signals.push(init.signal); + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ + timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), + model: "claude-sonnet-4-5", + conversationId: `conversation-${body.page}`, + isHeadless: body.page === 2, + chargedCents: 0, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 30, + cacheWriteTokens: 2, + totalCents: 25, + }, + })), + }); + }; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); + assert.isNull(result.error); + assert.deepStrictEqual(pages, [1, 2]); + assert.lengthOf(signals, 2); + assert.notStrictEqual(signals[0], signals[1]); + assert.strictEqual(result.records.length, 1001); + assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); + assert.deepStrictEqual(result.records[0]?.totals, { + uncachedInputTokens: 10, + cachedInputTokens: 30, + cacheCreationTokens: 2, + outputTokens: 5, + reasoningTokens: 0, + }); + assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); + assert.isFalse(result.accountKey?.includes("demo") ?? true); + }); + + it("reads Cursor account history beyond 100 pages", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests += 1; + return Response.json({ + totalUsageEventsCount: 100_001, + usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], + }); + }); + assert.isNull(result.error); + assert.strictEqual(requests, 101); + assert.deepStrictEqual(result.records, []); + }); + + it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + for (const body of [ + {}, + { totalUsageEventsCount: 0 }, + { totalUsageEventsCount: 0, usageEventsDisplay: [] }, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNull(result.error); + assert.deepStrictEqual(result.records, []); + assert.isFalse(result.missing); + } + for (const body of [ + { error: "upstream error" }, + { detail: "unknown error envelope" }, + { totalUsageEventsCount: 0, error: "upstream error" }, + null, + [], + "invalid", + 0, + ]) { + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json(body), + ); + assert.isNotNull(result.error); + assert.deepStrictEqual(result.records, []); + } + }); + + it("requires a terminal Cursor page after a full page reaches the reported count", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json( + requests === 1 + ? { + totalUsageEventsCount: 1000, + usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5 }, + })), + } + : { totalUsageEventsCount: 1000 }, + ); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, 1000); + assert.strictEqual(requests, 2); + }); + + it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const event = (index: number) => ({ + timestamp: String(1780000000000 + index), + model: "gpt-5", + tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, + }); + for (const total of [2000, 2001]) { + let requests = 0; + const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: total, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : requests === 2 + ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) + : [event(1999)], + }); + }); + assert.isNull(result.error); + assert.strictEqual(result.records.length, total); + assert.strictEqual(requests, 3); + assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); + assert.strictEqual( + result.records.filter((record) => record.timestampMs === 1780000000999).length, + total === 2000 ? 1 : 2, + ); + assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); + } + let requests = 0; + const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { + requests++; + return Response.json({ + totalUsageEventsCount: 1001, + usageEventsDisplay: + requests === 1 + ? Array.from({ length: 1000 }, (_, index) => event(index)) + : [event(500), event(1000)], + }); + }); + assert.isNotNull(inconsistent.error); + assert.deepStrictEqual(inconsistent.records, []); + }); + + it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { + const authPath = NodePath.join(dir, "auth.json"); + const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; + await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); + const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => + Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), + ); + assert.isNotNull(truncated.error); + assert.deepStrictEqual(truncated.records, []); + const denied = await readCursorAccountUsage( + authPath, + 0, + 1781000000000, + async () => new Response(accessToken, { status: 401 }), + ); + assert.isNotNull(denied.error); + assert.isFalse(denied.error?.includes(accessToken) ?? true); + assert.deepStrictEqual(denied.records, []); + let requested = false; + const missing = await readCursorAccountUsage( + NodePath.join(dir, "missing.json"), + 0, + 1781000000000, + async () => { + requested = true; + return Response.json({}); + }, + ); + assert.isTrue(missing.missing); + assert.isFalse(requested); + }); + + it("counts migrated OpenCode messages once and sees subsequent WAL writes", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "opencode.db")); + try { + db.exec( + "PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; CREATE TABLE message (id TEXT, session_id TEXT, data TEXT)", + ); + const message = { + id: "msg-1", + sessionID: "session-1", + role: "assistant", + modelID: "claude-sonnet-4-5", + time: { created: 1780000000000 }, + cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 30, write: 10 } }, + }; + const insert = db.prepare("INSERT INTO message VALUES (?, ?, ?)"); + insert.run(message.id, message.sessionID, JSON.stringify(message)); + const legacy = NodePath.join(dir, "storage", "message", message.sessionID); + await NodeFSP.mkdir(legacy, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(legacy, "msg-1.json"), JSON.stringify(message)); + const first = await readOpenCodeUsage(dir, 0); + assert.isFalse(first.error); + const records = first.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 30, + cacheCreationTokens: 10, + outputTokens: 25, + reasoningTokens: 5, + }); + assert.strictEqual(records[0]?.reportedCostUsd, 0.25); + insert.run( + "msg-2", + message.sessionID, + JSON.stringify({ ...message, id: "msg-2", time: { created: 1780000001000 } }), + ); + const next = await readOpenCodeUsage(dir, 1780000001000); + assert.isFalse(next.error); + assert.deepStrictEqual( + next.files.flatMap((file) => file.records).map((record) => record.dedupeKey), + ["opencode:msg-2"], + ); + assert.isAbove((await NodeFSP.stat(NodePath.join(dir, "opencode.db-wal"))).size, 0); + } finally { + db.close(); + } + }); + + it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); + const stamp = protoNumber(1, 1780000000); + const usage = [ + ...protoNumber(2, 100), + ...protoNumber(3, 40), + ...protoNumber(4, 5), + ...protoNumber(5, 20), + ...protoNumber(9, 10), + ...protoText(11, "response-1"), + ]; + const retry = [ + ...protoNumber(1, 1026), + ...protoNumber(2, 12), + ...protoNumber(3, 3), + ...protoText(11, "retry-1"), + ]; + const generation = protoBytes(1, [ + ...protoBytes(4, usage), + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(9, protoBytes(4, stamp)), + ]); + const step = [ + ...protoBytes(9, usage), + ...protoBytes(8, stamp), + ...protoBytes(28, protoBytes(2, retry)), + ]; + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run(0, new Uint8Array(generation)); + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(step)); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + const main = records.find((record) => record.model === "gemini-3-pro"); + assert.isDefined(main); + assert.strictEqual(main?.timestampMs, 1780000000000); + assert.strictEqual(main?.sessionId, "session-1"); + assert.deepStrictEqual(main?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 40, + reasoningTokens: 10, + }); + assert.strictEqual( + records.find((record) => record.model === "claude-opus-4-6")?.totals.uncachedInputTokens, + 12, + ); + assert.deepStrictEqual( + (await readAntigravityUsage(dir, 1780000000001)).files.flatMap((file) => file.records), + [], + ); + }); + + it("uses the matching Antigravity generation model for each model-less step", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "model-switch.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const generation = db.prepare("INSERT INTO gen_metadata VALUES (?, ?)"); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + for (const [idx, name] of ["Gemini 3 Pro", "Claude Opus 4.6"].entries()) { + generation.run(idx, new Uint8Array(protoBytes(1, protoText(19, name)))); + step.run(idx, new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx)))); + } + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.deepStrictEqual( + result.files.flatMap((file) => file.records).map((record) => record.model), + ["gemini-3-pro", "claude-opus-4-6"], + ); + }); + + it("merges Antigravity aliases that bridge previously separate step records", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "bridge.db")); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + const step = db.prepare("INSERT INTO steps VALUES (?, ?)"); + step.run( + 0, + new Uint8Array(protoBytes(9, [...protoNumber(2, 100), ...protoText(11, "response")])), + ); + step.run( + 1, + new Uint8Array(protoBytes(9, [...protoNumber(3, 40), ...protoText(12, "provider")])), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + 0, + new Uint8Array( + protoBytes(1, [ + ...protoText(19, "Gemini 3 Pro"), + ...protoBytes(4, [ + ...protoNumber(2, 50), + ...protoNumber(5, 20), + ...protoText(11, "response"), + ...protoText(12, "provider"), + ]), + ]), + ), + ); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 1); + assert.deepStrictEqual(records[0]?.totals, { + uncachedInputTokens: 100, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 40, + reasoningTokens: 0, + }); + }); + + it("merges Antigravity provider and message aliases across configured roots while keeping original ownership", async () => { + const roots = [NodePath.join(dir, "first"), NodePath.join(dir, "second")]; + for (const [index, root] of roots.entries()) { + await NodeFSP.mkdir(root); + const db = new NodeSqlite.DatabaseSync(NodePath.join(root, `session-${index}.db`)); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + for (const identity of [7, 12]) { + const usage = [ + ...protoNumber(1, 246), + ...protoNumber(2, index === 0 ? 100 : 150), + ...protoText(11, `response-${index}-${identity}`), + ...protoText(identity, `shared-${identity}`), + ]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + identity, + new Uint8Array(protoBytes(9, usage)), + ); + } + } finally { + db.close(); + } + } + const result = await readAntigravityUsage(roots, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0]?.root, roots[0]); + assert.strictEqual(result.files[0]?.records.length, 2); + assert.strictEqual(result.files[1]?.records.length, 0); + assert.deepStrictEqual( + result.files[0]?.records.map((record) => record.totals.uncachedInputTokens), + [150, 150], + ); + assert.isTrue(result.files[0]?.records.every((record) => record.sessionId === "session-0")); + }); + + it("upgrades Antigravity fallback timestamps before applying the date window", async () => { + for (const fallback of ["mtime", "trajectory"]) { + const path = NodePath.join(dir, `${fallback}.db`); + const db = new NodeSqlite.DatabaseSync(path); + try { + db.exec( + "CREATE TABLE gen_metadata (idx INTEGER, data BLOB); CREATE TABLE steps (idx INTEGER, metadata BLOB)", + ); + if (fallback === "trajectory") { + db.exec("CREATE TABLE trajectory_metadata_blob (data BLOB)"); + db.prepare("INSERT INTO trajectory_metadata_blob VALUES (?)").run( + new Uint8Array(protoBytes(2, protoNumber(1, 1780000200))), + ); + } + for (const [index, seconds] of [1780000000, 1780000200].entries()) { + const usage = [...protoNumber(2, 10), ...protoText(11, `${fallback}-${index}`)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + index, + new Uint8Array(protoBytes(9, usage)), + ); + db.prepare("INSERT INTO gen_metadata VALUES (?, ?)").run( + index, + new Uint8Array( + protoBytes(1, [ + ...protoBytes(4, usage), + ...protoBytes(9, protoBytes(4, protoNumber(1, seconds))), + ]), + ), + ); + } + } finally { + db.close(); + } + await NodeFSP.utimes(path, 1780000000, 1780000000); + } + const result = await readAntigravityUsage(dir, 1780000100000); + assert.deepStrictEqual(result.errors, []); + const records = result.files.flatMap((file) => file.records); + assert.strictEqual(records.length, 2); + assert.deepStrictEqual( + records.map((record) => record.timestampMs), + [1780000200000, 1780000200000], + ); + }); + + it("reads Antigravity step-only stores and reports malformed databases", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "steps.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...protoNumber(3, 5)]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run( + 0, + new Uint8Array([...protoBytes(9, usage), ...protoBytes(8, protoNumber(1, 1780000000))]), + ); + } finally { + db.close(); + } + await NodeFSP.writeFile(NodePath.join(dir, "broken.db"), "not a sqlite database"); + const result = await readAntigravityUsage(dir, 0); + assert.strictEqual(result.errors.length, 1); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.model, "gemini-2.5-pro"); + assert.strictEqual(result.files.flatMap((file) => file.records)[0]?.totals.outputTokens, 5); + }); + + it("ignores large values in unused Antigravity protobuf fields", async () => { + const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "large-varint.db")); + try { + db.exec("CREATE TABLE steps (idx INTEGER, metadata BLOB)"); + const unusedField = [...protoNumber(99, 0).slice(0, -1), ...Array(9).fill(0xff), 0x01]; + const usage = [...protoNumber(1, 246), ...protoNumber(2, 10), ...unusedField]; + db.prepare("INSERT INTO steps VALUES (?, ?)").run(0, new Uint8Array(protoBytes(9, usage))); + } finally { + db.close(); + } + const result = await readAntigravityUsage(dir, 0); + assert.deepStrictEqual(result.errors, []); + assert.strictEqual( + result.files.flatMap((file) => file.records)[0]?.totals.uncachedInputTokens, + 10, + ); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 9e5ab6e0c9e0..faa686990777 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -17,15 +17,21 @@ */ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeStringDecoder from "node:string_decoder"; import type { UsageProviderKind } from "@t3tools/contracts"; +import { createTranscriptJsonReader } from "../project/AgentSessionJson.ts"; + import { initialCodexScanState, mightCarryUsage, parseClaudeLine, + parseClaudeRecord, parseCodexLine, + parseCodexRecord, parseGrokLine, + parseGrokRecord, type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -74,9 +80,62 @@ export interface TranscriptParseResult { /** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ export const GUARD_LENGTH = 64; +// Native parsing is faster for common 1–4 MiB context/tool records. Above +// 8 MiB, project usage without allocating the whole record. This switches +// readers; it never discards a record because of its size. +const STREAMING_THRESHOLD_BYTES = 8 * 1024 * 1024; const NEWLINE = 0x0a; const CARRIAGE_RETURN = 0x0d; +type SelectedFields = { readonly [key: string]: true | SelectedFields }; + +// Keep the fields consumed by usageTranscripts, including reducer state and +// dedupe/cost metadata. A selected subtree (usage) keeps future token fields. +const USAGE_FIELDS: Record<"claude" | "codex" | "grok", SelectedFields> = { + claude: { + type: true, + timestamp: true, + requestId: true, + sessionId: true, + costUSD: true, + message: { id: true, model: true, usage: true }, + }, + codex: { + type: true, + timestamp: true, + payload: { + type: true, + id: true, + session_id: true, + model: true, + forked_from_id: true, + source: { subagent: { thread_spawn: { parent_thread_id: true } } }, + info: { last_token_usage: true }, + }, + }, + grok: { + timestamp: true, + params: { + sessionId: true, + _meta: { agentTimestampMs: true }, + update: { sessionUpdate: true, prompt_id: true, usage: true }, + }, + }, +}; + +function selectUsageFields(provider: UsageProviderKind) { + const fields = USAGE_FIELDS[provider === "codex" || provider === "grok" ? provider : "claude"]; + return (path: ReadonlyArray): boolean => { + let selected: true | SelectedFields = fields; + for (const key of path) { + if (selected === true) return true; + if (typeof key !== "string" || !Object.hasOwn(selected, key)) return false; + selected = selected[key]!; + } + return true; + }; +} + function fnv1a(buffer: Buffer): number { let hash = 0x811c9dc5; for (let index = 0; index < buffer.length; index += 1) { @@ -194,7 +253,9 @@ export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, resumeFrom?: TranscriptParsePosition, + options?: { readonly streamingThresholdBytes?: number }, ): Promise { + const streamingThresholdBytes = options?.streamingThresholdBytes ?? STREAMING_THRESHOLD_BYTES; let handle: NodeFSP.FileHandle; try { handle = await NodeFSP.open(filePath, "r"); @@ -248,43 +309,87 @@ export async function readTranscriptRecords( }; const records: UsageRecord[] = []; - // Buffer-level line splitting rather than `readline`, because resuming - // needs byte-exact offsets and decoded strings cannot provide them. - // Newline-free chunks are collected rather than concatenated as they - // arrive, so a single huge line costs one copy instead of one per chunk. + // Byte offsets remain independent of UTF-8 decoding. Only complete lines + // commit the resume point; an unfinished tail is replayed on the next scan. let resumeOffset = start; + let scanOffset = start; let pendingChunks: Buffer[] = []; + let pendingBytes = 0; + let streaming: ReturnType | undefined; + let decoder: NodeStringDecoder.StringDecoder | undefined; + const selectPath = selectUsageFields(provider); + + const append = (segment: Buffer) => { + if (!streaming && pendingBytes + segment.length <= streamingThresholdBytes) { + if (segment.length > 0) pendingChunks.push(segment); + pendingBytes += segment.length; + return; + } + if (!streaming) { + // Usage has no import-history budget: retain all selected usage fields, + // regardless of the size of the surrounding unselected tool content. + streaming = createTranscriptJsonReader(() => {}, selectPath, { maxDepth: Infinity }); + decoder = new NodeStringDecoder.StringDecoder("utf8"); + for (const pending of pendingChunks) streaming.write(decoder.write(pending)); + pendingChunks = []; + pendingBytes = 0; + } + streaming.write(decoder!.write(segment)); + }; + const finish = (state: CodexScanState, out: UsageRecord[]) => { + if (streaming) { + streaming.write(decoder!.end()); + const projected = streaming.finish(); + if (provider === "grok") { + out.push(...parseGrokRecord(projected)); + } else { + const record = + provider === "codex" + ? parseCodexRecord(projected, state) + : parseClaudeRecord(projected); + if (record !== null) out.push(record); + } + } else if (pendingBytes > 0) { + const line = + pendingChunks.length === 1 + ? pendingChunks[0]! + : Buffer.concat(pendingChunks, pendingBytes); + parseLine(toLineString(line), state, out); + } + pendingChunks = []; + pendingBytes = 0; + streaming = undefined; + decoder = undefined; + }; const stream = handle.createReadStream({ start, autoClose: false, + highWaterMark: 256 * 1024, }) as AsyncIterable; for await (const chunk of stream) { - if (!chunk.includes(NEWLINE)) { - pendingChunks.push(chunk); - continue; - } - const buffer: Buffer = - pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); - pendingChunks = []; let lineStart = 0; - for (;;) { - const newlineIndex = buffer.indexOf(NEWLINE, lineStart); - if (newlineIndex === -1) break; - parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + while (lineStart < chunk.length) { + const newlineIndex = chunk.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) { + append(chunk.subarray(lineStart)); + break; + } + // Most lines fit in the current chunk. Avoid buffering/streaming + // machinery on this hot path. + if (!streaming && pendingBytes === 0) { + parseLine(toLineString(chunk.subarray(lineStart, newlineIndex)), codexState, records); + } else { + append(chunk.subarray(lineStart, newlineIndex)); + finish(codexState, records); + } lineStart = newlineIndex + 1; + resumeOffset = scanOffset + lineStart; } - resumeOffset += lineStart; - if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + scanOffset += chunk.length; } - // A trailing segment without its newline is parsed for this result but not - // consumed: a writer may still be appending to it, and counting a half - // record now and its full form later would double count. const tailRecords: UsageRecord[] = []; - if (pendingChunks.length > 0) { - const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); - if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); - } + finish({ ...codexState }, tailRecords); const guardLength = Math.min(GUARD_LENGTH, resumeOffset); let guardHash = 0; diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts new file mode 100644 index 000000000000..2187fd4aa431 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercise the real byte reader. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; + +import { + readTranscriptRecords as readWithDefaultThreshold, + type TranscriptParsePosition, +} from "./usageTranscriptReader.ts"; + +// Exercise the same transition with compact fixtures. UsageService tests and +// external 65/517 MiB fixtures also exercise the production threshold. +const readTranscriptRecords = ( + path: string, + provider: "claude" | "codex" | "grok", + position?: TranscriptParsePosition, +) => readWithDefaultThreshold(path, provider, position, { streamingThresholdBytes: 256 * 1024 }); + +let dir: string; +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-stream-")); +}); +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +const timestamp = "2026-08-01T10:00:00Z"; +const content = '工具 output \\" usage token_count '.repeat(40_000); +const claude = (id = "m1", output = 99) => ({ + type: "assistant", + timestamp, + sessionId: "s1", + requestId: `r-${id}`, + costUSD: 0.25, + message: { + content: [{ type: "tool_use", input: { text: content } }], + id, + model: "claude-fable-5", + usage: { + input_tokens: 100, + output_tokens: output, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 5, + speed: "fast", + }, + }, +}); +const codex = [ + { type: "session_meta", timestamp, payload: { id: "s1" } }, + { type: "turn_context", timestamp, payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + info: { + last_token_usage: { + input_tokens: 100, + output_tokens: 99, + cached_input_tokens: 20, + cache_write_input_tokens: 5, + reasoning_output_tokens: 10, + }, + }, + }, + }, +]; +const grok = { + timestamp: 1785578400, + params: { + sessionId: "s1", + _meta: { agentTimestampMs: 1785578400123 }, + update: { + sessionUpdate: "turn_completed", + prompt_id: "p1", + usage: { + inputTokens: 100, + outputTokens: 99, + costUsdTicks: 2500000000, + modelUsage: { + "grok-4.5-build": { + inputTokens: 100, + outputTokens: 99, + cachedReadTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 10, + }, + }, + }, + }, + }, +}; + +async function scan( + lines: readonly unknown[], + provider: "claude" | "codex" | "grok", + name = "history", +) { + const path = NodePath.join(dir, `${name}.jsonl`); + await NodeFSP.writeFile(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n"); + const result = await readTranscriptRecords(path, provider); + expect(result).not.toBeNull(); + return result!; +} + +describe("large usage records", () => { + it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { + const result = await scan([claude()], "claude"); + expect(result.records).toEqual([ + { + provider: "claude", + timestampMs: Date.parse(timestamp), + sessionId: "s1", + model: "claude-fable-5", + totals: { + uncachedInputTokens: 100, + outputTokens: 99, + cachedInputTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 0, + }, + reportedCostUsd: 0.25, + fast: true, + dedupeKey: "m1:r-m1", + }, + ]); + }); + + it.each(["claude", "codex", "grok"] as const)( + "matches ordinary %s records with large irrelevant fields in either order", + async (provider) => { + const small = + provider === "codex" + ? codex + : provider === "grok" + ? [grok] + : [{ ...claude(), message: { ...claude().message, content: [] } }]; + const expected = await scan(small, provider, "small"); + for (const first of [true, false]) { + const large = small.map((record) => + first ? { padding: content, ...record } : { ...record, padding: content }, + ); + const actual = await scan(large, provider); + expect(actual.records).toEqual(expected.records); + expect(actual.position.codexState).toEqual(expected.position.codexState); + } + }, + ); + + it("preserves Grok model allocation, precise timestamp and prompt identity", async () => { + const result = await scan([{ padding: content, ...grok }], "grok"); + expect(result.records[0]).toMatchObject({ + timestampMs: 1785578400123, + model: "grok-4.5-build", + sessionId: "s1", + totals: { + uncachedInputTokens: 75, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 99, + reasoningTokens: 10, + }, + dedupeKey: "s1:p1:grok-4.5-build", + reportedCostUsd: 0.25, + }); + }); + + it("does not count usage-looking text or nested objects inside tool output", async () => { + const result = await scan( + [ + { type: "user", padding: content, toolOutput: claude() }, + { padding: content, message: { content: JSON.stringify(claude()) } }, + { type: "assistant", timestamp, message: { model: "claude-fable-5", content: [claude()] } }, + ], + "claude", + ); + expect(result.records).toEqual([]); + }); + + it("replays partial UTF-8 and JSON tails, commits exact CRLF offsets, and replaces the tail once", async () => { + const path = NodePath.join(dir, "tail.jsonl"); + const first = JSON.stringify(claude("first", 5)) + "\r\n"; + const second = Buffer.from(JSON.stringify(claude("second", 7))); + const split = second.lastIndexOf(Buffer.from("工具")) + 1; + await NodeFSP.writeFile(path, Buffer.concat([Buffer.from(first), second.subarray(0, split)])); + const partial = await readTranscriptRecords(path, "claude"); + expect(partial?.records.map((r) => r.totals.outputTokens)).toEqual([5]); + expect(partial?.tailRecords).toEqual([]); + expect(partial?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, second.subarray(split)); + const complete = await readTranscriptRecords(path, "claude", partial!.position); + expect(complete?.resumed).toBe(true); + expect(complete?.records).toEqual([]); + expect(complete?.tailRecords.map((r) => r.totals.outputTokens)).toEqual([7]); + expect(complete?.position.resumeOffset).toBe(Buffer.byteLength(first)); + await NodeFSP.appendFile(path, "\r\n" + JSON.stringify(claude("third", 11)) + "\n"); + const appended = await readTranscriptRecords(path, "claude", complete!.position); + expect(appended?.records.map((r) => r.totals.outputTokens)).toEqual([7, 11]); + expect(appended?.tailRecords).toEqual([]); + expect(appended?.position.resumeOffset).toBe((await NodeFSP.stat(path)).size); + const full = await readTranscriptRecords(path, "claude"); + expect([...partial!.records, ...appended!.records]).toEqual(full?.records); + }); + + it("preserves Codex model switches and duplicate suppression across streaming resumes", async () => { + const path = NodePath.join(dir, "history.jsonl"); + const first = await scan( + codex.map((record) => ({ padding: content, ...record })), + "codex", + ); + await NodeFSP.appendFile( + path, + [ + { padding: content, ...codex[2] }, + { type: "turn_context", payload: { padding: content, model: "gpt-6" } }, + { + type: "event_msg", + timestamp, + payload: { + type: "token_count", + padding: content, + info: { last_token_usage: { input_tokens: 200, output_tokens: 101 } }, + }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n") + "\n", + ); + const result = await readTranscriptRecords(path, "codex", first.position); + expect(result?.resumed).toBe(true); + expect(result?.records).toHaveLength(1); + expect(result?.records[0]).toMatchObject({ + model: "gpt-6", + sessionId: "s1", + totals: { outputTokens: 101 }, + }); + const full = await readTranscriptRecords(path, "codex"); + expect([...first.records, ...result!.records]).toEqual(full?.records); + }); + + it.each(["forked_from_id", "source"])( + "preserves Codex fork-copy suppression from %s", + async (field) => { + const fork = + field === "source" + ? { source: { subagent: { thread_spawn: { parent_thread_id: "parent" } } } } + : { forked_from_id: "parent" }; + const lines = [ + { type: "session_meta", timestamp, payload: { padding: content, id: "child", ...fork } }, + codex[1], + codex[2], + { + ...codex[2], + timestamp: "2026-08-01T10:00:05Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: 101 } }, + }, + }, + ]; + const result = await scan(lines, "codex"); + expect(result.records).toHaveLength(1); + expect(result.records[0]).toMatchObject({ + sessionId: "child", + totals: { outputTokens: 101 }, + }); + }, + ); + + it("rejects malformed large lines without accepting partial usage or losing following lines", async () => { + const valid = JSON.stringify(claude()); + const path = NodePath.join(dir, "broken.jsonl"); + await NodeFSP.writeFile( + path, + [valid + " junk", valid.slice(0, -1), valid + valid, JSON.stringify(claude("good", 7))].join( + "\n", + ) + "\n", + ); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records.map((r) => r.totals.outputTokens)).toEqual([7]); + }); + + it("matches JSON.parse for reordered and escaped keys, duplicate fields, arrays and unknown key names", async () => { + const path = NodePath.join(dir, "odd.jsonl"); + const small = JSON.stringify({ ...claude(), message: { ...claude().message, content: [] } }); + const variants = [ + small.replace('"message":', '"mess\\u0061ge":'), + small.replace('"costUSD":0.25', '"costUSD":5,"costUSD":0.25'), + small.replace('"type":"assistant"', '"type":"user","type":"assistant"'), + small.replace('"message":', '"__proto__":{"type":"user"},"message":'), + small.replace('"message":', '"message.usage":{"output_tokens":1234},"message":'), + ]; + for (const line of variants) { + await NodeFSP.writeFile(path, line + "\n"); + const expected = await readTranscriptRecords(path, "claude"); + await NodeFSP.writeFile( + path, + '{"padding":' + JSON.stringify(content) + "," + line.slice(1) + "\n", + ); + const actual = await readTranscriptRecords(path, "claude"); + expect(actual?.records).toEqual(expected?.records); + } + }); + it("reads usage after deeply nested discarded tool content", async () => { + const path = NodePath.join(dir, "deep.jsonl"); + const record = JSON.stringify(claude()); + const nested = "[".repeat(300) + "0" + "]".repeat(300); + await NodeFSP.writeFile(path, '{"toolOutput":' + nested + "," + record.slice(1) + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); + + it("keeps JSON number semantics for non-finite values without serializing them into null", async () => { + const path = NodePath.join(dir, "numbers.jsonl"); + const record = JSON.stringify(claude()).replace('"costUSD":0.25', '"costUSD":1e400'); + await NodeFSP.writeFile(path, record + "\n"); + const result = await readTranscriptRecords(path, "claude"); + expect(result?.records[0]?.reportedCostUsd).toBeNull(); + expect(result?.records[0]?.totals.outputTokens).toBe(99); + }); +}); diff --git a/apps/server/src/usage/usageTranscripts.test.ts b/apps/server/src/usage/usageTranscripts.test.ts index b09db613ed85..ace3b7d18cf7 100644 --- a/apps/server/src/usage/usageTranscripts.test.ts +++ b/apps/server/src/usage/usageTranscripts.test.ts @@ -15,6 +15,7 @@ function claudeLine(overrides: { contentType: string; model?: string; outputTokens?: number; + speed?: string; }): string { return JSON.stringify({ type: "assistant", @@ -31,6 +32,7 @@ function claudeLine(overrides: { cache_creation_input_tokens: 66818, cache_read_input_tokens: 1000, output_tokens: overrides.outputTokens ?? 286, + ...(overrides.speed === undefined ? {} : { speed: overrides.speed }), }, }, }); @@ -51,6 +53,15 @@ describe("parseClaudeLine", () => { reasoningTokens: 0, }); expect(record?.dedupeKey).toBe("msg_1:"); + expect(record?.fast).toBe(false); + }); + + it("marks fast-mode requests", () => { + const line = (speed: string) => + parseClaudeLine(claudeLine({ messageId: "msg_1", contentType: "text", speed })); + + expect(line("fast")?.fast).toBe(true); + expect(line("standard")?.fast).toBe(false); }); it("gives every content block of one message the same dedupe key", () => { diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5d909379eb10..c3903ef47194 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -12,9 +12,19 @@ export interface UsageRecord { readonly provider: UsageProviderKind; readonly timestampMs: number; readonly model: string; + /** + * Rate-table key when the provider's display name carries tiers the table + * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. + */ + readonly rateModel?: string; readonly sessionId: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; + /** + * Whether the request ran in fast mode, which bills at a model-specific + * multiple of the standard rate. Only Claude Code records this. + */ + readonly fast: boolean; /** * Key for cross-file de-duplication, or `null` when the record is inherently * unique and needs no dedup. @@ -103,6 +113,10 @@ export function parseClaudeLine(line: string): UsageRecord | null { } catch { return null; } + return parseClaudeRecord(parsed); +} + +export function parseClaudeRecord(parsed: unknown): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -145,6 +159,7 @@ export function parseClaudeLine(line: string): UsageRecord | null { reasoningTokens: 0, }, reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null, + fast: usageRecord["speed"] === "fast", dedupeKey, }; } @@ -217,6 +232,10 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord } catch { return null; } + return parseCodexRecord(parsed, state); +} + +export function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | null { if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; @@ -304,6 +323,7 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord totals, // Codex does not report cost in the rollout. reportedCostUsd: null, + fast: false, // Events surviving the fork-copy suppression above are unique to this // rollout, so they need no global dedup. dedupeKey: null, @@ -373,6 +393,10 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { } catch { return []; } + return parseGrokRecord(parsed); +} + +export function parseGrokRecord(parsed: unknown): readonly UsageRecord[] { if (typeof parsed !== "object" || parsed === null) return []; const record = parsed as Record; @@ -433,6 +457,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { sessionId, totals: grokTotalsToUsage(topLevel), reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks), + fast: false, // No prompt id means we cannot tell two same-second updates apart. dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`, }, @@ -479,6 +504,7 @@ export function parseGrokLine(line: string): readonly UsageRecord[] { sessionId, totals, reportedCostUsd, + fast: false, dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`, }); } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 247f2827c979..3cea8796f726 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1101,7 +1101,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) && (yield* fileSystem.readDirectory(input.cwd).pipe( Effect.map((entries) => entries.length === 0), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), )); if (!emptiedWorkspace) return yield* new VcsProcessExitError({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 98dec86522de..85c4d1a59d1d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1449,6 +1449,89 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + for (const splitIndex of [false, true]) { + it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); + yield* writeTextFile(cwd, "stable.txt", "unchanged\n"); + yield* writeTextFile( + cwd, + ".git/filter.cjs", + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "cache stable file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, ".git/filter-runs", ""); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")!.files, + [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); + assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); + } + + for (const [timestamp, splitIndex] of [ + [1_700_000_000, false], + [1_700_000_000.9999, false], + [1_700_000_000, true], + [1_700_000_000.9999, true], + ] as const) { + it.effect( + `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + // Reproduce a same-timestamp edit without relying on filesystem clock resolution. + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "record racy file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }), + ); + } + it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -1835,6 +1918,35 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("does not start Git auto-maintenance from background upstream fetches", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["repack", "-d"]); + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push"]); + yield* git(cwd, ["repack", "-d"]); + // Two packs make `git gc --auto` due, and without detaching it would run inside the fetch. + yield* git(cwd, ["config", "gc.autoPackLimit", "1"]); + yield* git(cwd, ["config", "gc.autoDetach", "false"]); + yield* git(cwd, ["config", "maintenance.autoDetach", "false"]); + const packCount = git(cwd, ["count-objects", "-v"]).pipe( + Effect.map((stdout) => stdout.match(/^packs: (\d+)$/m)?.[1]), + ); + assert.equal(yield* packCount, "2"); + + yield* (yield* GitVcsDriver.GitVcsDriver).statusDetailsRemote(cwd); + + assert.equal(yield* packCount, "2"); + }), + ); + it.effect("uses origin HEAD for default-branch detection with a non-origin upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 9f91264ee623..0dd73af687f9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -549,7 +549,9 @@ function trace2ChildKey(record: Record): string | null { const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); const decodeTrace2Record = decodeJsonResult(Trace2Record); -const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( +// Untraced because it runs on every git spawn and returns at once without hook +// callbacks. Its errors fail the runGitCommand span. +const createTrace2Monitor = Effect.fnUntraced(function* ( input: Pick, progress: GitVcsDriver.ExecuteGitProgress | undefined, ): Effect.fn.Return< @@ -946,16 +948,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* execution.pipe( Effect.timeoutOption(timeoutMs), Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command timed out.", - }), - ), - onSome: Effect.succeed, - }), + Effect.fromOption( + result, + () => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command timed out.", + }), + ), ), ); }, @@ -1004,11 +1004,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : {}), ...(options.progress ? { progress: options.progress } : {}), }).pipe( - Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.exitCode === 0) { - return Effect.succeed(result); - } - return Effect.fail( + Effect.filterOrFail( + (result) => options.allowNonZeroExit || result.exitCode === 0, + (result) => new GitCommandError({ ...gitCommandContext({ operation, cwd, args }), detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", @@ -1016,8 +1014,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stdoutLength: result.stdout.length, stderrLength: result.stderr.length, }), - ); - }), + ), ); const executeGitWithStableDiagnostics = ( @@ -1130,10 +1127,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + // `--no-auto-gc` (a synonym of `--no-auto-maintenance` that older Git also knows) keeps + // this poll from starting `git gc --auto`. When that gc fails, for example on a repository + // with missing objects, Git retries it on every fetch and leaves a full-size `tmp_pack_*` + // behind each time, so a background poll could fill the disk. return executeGit( "GitVcsDriver.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", "--no-auto-gc", remoteName], { env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", @@ -2285,13 +2286,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2300,7 +2303,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2309,7 +2312,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2358,7 +2361,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* prefix: `t3code-review-index-${process.pid}-`, }); const indexExists = yield* fileSystem.exists(indexPath); - if (indexExists) yield* fileSystem.copyFile(indexPath, tempIndexPath); + if (indexExists) { + const { mtime } = yield* fileSystem.stat(indexPath); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. + // Flooring preserves the source second without making preceding-second files racy. + const indexTime = Option.isSome(mtime) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) + : 0; + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + } const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; const tempIndexConfig = [ "-c", diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index f106767da9c8..970de50c12f9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -592,13 +592,13 @@ export const make = Effect.gen(function* () { const demandCwds = yield* Ref.get(demandCwdsRef); const shouldRun = needsInitialRefresh || - (yield* Effect.all( - [...demandCwds.keys()].map((demandCwd) => + (yield* Effect.forEach( + [...demandCwds.keys()], + (demandCwd) => backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd, }), - ), { concurrency: REMOTE_STATUS_REFRESH_CONCURRENCY }, )).some(Boolean); if (!shouldRun) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 53bdaec5bc6e..7a6e7928d79a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,6 +17,8 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import { subscribeChatGptHandoff } from "./provider/CodexChatGptHandoff.ts"; +import { subscribeCodexAuthCallback } from "./provider/CodexAuthCallback.ts"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthAccessStreamError, @@ -112,6 +114,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -566,6 +570,8 @@ const makeWsRpcLayer = ( yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -855,27 +861,23 @@ const makeWsRpcLayer = ( case "project.meta-updated": return projectUpsertOrRemove(ProjectId.make(event.aggregateId), event.sequence); case "project.deleted": - return Effect.succeed( - Option.some({ - kind: "project-removed" as const, - sequence: event.sequence, - projectId: ProjectId.make(event.aggregateId), - }), - ); + return Effect.succeedSome({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: ProjectId.make(event.aggregateId), + }); case "thread.deleted": case "thread.archived": - return Effect.succeed( - Option.some({ - kind: "thread-removed" as const, - sequence: event.sequence, - threadId: ThreadId.make(event.aggregateId), - }), - ); + return Effect.succeedSome({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: ThreadId.make(event.aggregateId), + }); case "thread.unarchived": return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); default: if (event.aggregateKind !== "thread") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } @@ -893,7 +895,7 @@ const makeWsRpcLayer = ( ): Effect.Effect, never, never> => read.pipe( Effect.retry({ times: 1 }), - Effect.map(Option.some), + Effect.asSome, Effect.tapError((error) => Effect.logWarning("orchestration shell projection refetch failed", { aggregateKind, @@ -2368,6 +2370,28 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + // Only explicit catalog refreshes bypass T3's caches. Workspace + // discovery and background status checks retain their timers. + if (input.refreshModels) { + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) + providerVersionCache.delete(maintenance.packageName); + }), + { concurrency: "unbounded", discard: true }, + ); + } // An untargeted refresh is "re-read everything's status", which // includes quota from configured usage-limit sources. Awaited, // not forked: the RPC scope closes on return and would @@ -2478,12 +2502,33 @@ const makeWsRpcLayer = ( providerAuth.start(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.providerAuthRespond]: (input) => + observeRpcEffect( + WS_METHODS.providerAuthRespond, + providerAuth.respond(input, currentSessionId), + { + "rpc.aggregate": "provider", + instanceId: input.instanceId, + }, + ), [WS_METHODS.providerAuthComplete]: (input) => observeRpcEffect( WS_METHODS.providerAuthComplete, providerAuth.complete(input, currentSessionId), { "rpc.aggregate": "provider" }, ), + [WS_METHODS.chatGptReconnectProfile]: (input) => providerAuth.reconnectProfile(input), + [WS_METHODS.chatGptImportProfile]: (input) => providerAuth.importProfile(input), + [WS_METHODS.chatGptHandoffSubscribe]: (input) => + subscribeChatGptHandoff(input, currentSessionId), + [WS_METHODS.codexAuthCallbackSubscribe]: (input) => + observeRpcStream( + WS_METHODS.codexAuthCallbackSubscribe, + subscribeCodexAuthCallback(input), + { + "rpc.aggregate": "provider", + }, + ), [WS_METHODS.providerAuthCancel]: (input) => observeRpcEffect( WS_METHODS.providerAuthCancel, @@ -3014,9 +3059,13 @@ const makeWsRpcLayer = ( [WS_METHODS.sourceControlPublishRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlPublishRepository, - sourceControlRepositories - .publishRepository(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + sourceControlRepositories.publishRepository(input).pipe( + // A new remote can change the cached identity. Only the `cwd` entry + // refreshes, so after a publish from a linked worktree the project + // root entry waits for its TTL. + Effect.tap(() => repositoryIdentityResolver.resolve(input.cwd, { refresh: true })), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), { "rpc.aggregate": "source-control", }, diff --git a/apps/web/package.json b/apps/web/package.json index ddd87c6526cf..9adb7e4c828d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.53", + "version": "0.0.54", "private": true, "type": "module", "scripts": { @@ -76,6 +76,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "jsdom": "^30.0.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "unified": "^11.0.5", diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index e8ad80b597ba..390c77de4424 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -175,7 +175,7 @@ export function BrowserDeviceToolbar({ }} > {width >= 560 ? ( - + Dimensions ) : null} @@ -189,15 +189,12 @@ export function BrowserDeviceToolbar({ = 440 ? "w-36" : "w-24", - )} + className={cn("shrink-0 justify-between", width >= 440 ? "w-36" : "w-24")} aria-label="Browser device preset" > - + Responsive Standard @@ -227,7 +224,8 @@ export function BrowserDeviceToolbar({ nativeInput type="number" inputMode="numeric" - size="sm" + size="compact" + font="mono" min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} value={presentedSize.width} @@ -244,17 +242,15 @@ export function BrowserDeviceToolbar({ onChange={(event) => updateCustomDimension("width", event.target.value)} aria-label="Viewport width" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> × updateCustomDimension("height", event.target.value)} aria-label="Viewport height" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> @@ -282,14 +275,13 @@ export function BrowserDeviceToolbar({ event.preventDefault()} onClick={toggleAspectRatio} @@ -316,19 +308,21 @@ export function BrowserDeviceToolbar({ > - + {/* Sticky backing so scrolled controls do not show through the close action. */} + + +
); } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 6c303afb27e9..35044cd9c169 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -352,7 +352,7 @@ export function HostedBrowserWebview(props: { /> {activeDrag ? (
{agent.title} {role ? ( - + {role} ) : null} - + {agent.status === "completed" ? ( @@ -183,7 +183,7 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { > {activity ?? statusLabel} - + {metadata.join(" · ")} {statusLabel} @@ -234,7 +234,7 @@ function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { > {phase.members.length === 0 ? ( - – + – ) : ( phase.members.map((member) => ) )} @@ -281,7 +281,7 @@ function WorkflowScriptView({
- + {scriptPath.split("/").at(-1)}
); } @@ -920,12 +940,16 @@ function MarkdownCodeBlock({ language, fenceTitle, theme, + onRunShellCommand, + isStreaming, children, }: { code: string; language: string; fenceTitle: string | null; theme: "light" | "dark"; + onRunShellCommand?: ((command: string) => void) | undefined; + isStreaming: boolean; children: ReactNode; }) { const [copied, setCopied] = useState(false); @@ -933,6 +957,17 @@ function MarkdownCodeBlock({ const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const command = code.trim(); + const canRun = + onRunShellCommand !== undefined && + !isStreaming && + /^(?:sh|bash|zsh|fish|shell|powershell|pwsh)$/.test(language) && + code.endsWith("\n") && + command.length > 0 && + !command.endsWith("\\") && + // Control and invisible format characters (bidi overrides, zero-width) can + // make the rendered command differ from what the terminal would receive. + !/[\p{Cc}\p{Cf}]/u.test(code.slice(0, -1)); const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { @@ -974,12 +1009,12 @@ function MarkdownCodeBlock({ return (
- + setWrapped((value) => !value)} aria-label={wrapLabel} @@ -1005,14 +1039,31 @@ function MarkdownCodeBlock({ {wrapLabel} + {canRun ? ( + + onRunShellCommand(command)} + aria-label="Run in terminal" + /> + } + > + + + Run in terminal + + ) : null} @@ -1159,11 +1210,9 @@ interface MarkdownFileLinkProps { /** Platform-specific menu label ("Reveal in Finder", ...); required for the reveal item to show. */ revealLabel?: string | undefined; - className?: string | undefined; } -const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; -const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70`; +const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); @@ -1899,7 +1948,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenMedia, onReveal, revealLabel, - className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { if (!onOpen) { @@ -2229,13 +2277,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } + className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} onClick={(event) => { event.preventDefault(); @@ -2252,35 +2297,28 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }} onContextMenu={handleContextMenu} > - - + + ) : ( - + + ) } /> - + {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
+
{targetPath}
@@ -2310,8 +2348,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel && - previous.className === next.className + previous.revealLabel === next.revealLabel ); } @@ -2325,6 +2362,7 @@ function useChatMarkdownState({ isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, onUseArtifactTemplate, + onRunShellCommand, imageBaseDir, onImageExpand, renderContextReference, @@ -2660,12 +2698,7 @@ function useChatMarkdownState({ [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( - ( - fileLinkMeta: MarkdownFileLinkMeta, - copyMarkdown: string, - className?: string, - mediaSource?: string, - ) => { + (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); @@ -2723,7 +2756,6 @@ function useChatMarkdownState({ ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } - className={className} /> ); }, @@ -2759,6 +2791,7 @@ function useChatMarkdownState({ markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, + onRunShellCommand, openChangeRequestLink, openDeferredMarkdownLink, openExternalLinkInPreview, @@ -2789,6 +2822,7 @@ function useChatMarkdownState({ markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, + onRunShellCommand, openChangeRequestLink, openDeferredMarkdownLink, openExternalLinkInPreview, @@ -3150,12 +3184,7 @@ const CHAT_MARKDOWN_COMPONENTS = { return ( - - {href} - + {href} ); } @@ -3163,7 +3192,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, normalizedHref, ); }, @@ -3180,7 +3208,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `\`${codeText}\``, - undefined, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } @@ -3327,7 +3354,9 @@ const CHAT_MARKDOWN_COMPONENTS = { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { - const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); + const { resolvedTheme, diffThemeName, isStreaming, onRunShellCommand, text } = use( + ChatMarkdownRendererContext, + ); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; @@ -3341,6 +3370,12 @@ const CHAT_MARKDOWN_COMPONENTS = { language={language} fenceTitle={fenceTitle} theme={resolvedTheme} + onRunShellCommand={ + onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) + ? onRunShellCommand + : undefined + } + isStreaming={isStreaming} > { }); }); -describe("toolGroupConsumesUpwardNavigation", () => { - class ScrollElement extends EventTarget { - scrollTop = 0; - scrollHeight = 100; - clientHeight = 100; - overflowY = "visible"; - - constructor( - readonly parentElement: ScrollElement | null = null, - readonly isToolGroup = false, - ) { - super(); - } - - closest(selector: string): ScrollElement | null { - if (selector !== "[data-tool-group-scroll]") return null; - return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); - } - } - - beforeEach(() => { - vi.stubGlobal("Element", ScrollElement); - vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ - overflowY: element.overflowY, - })); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("releases upward navigation when an overflowing group is at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each([ - { overflowY: "auto", scrollTop: 1 }, - { overflowY: "auto", scrollTop: 0.25 }, - { overflowY: "scroll", scrollTop: 80 }, - ])("consumes upward navigation within a scrolled group: %j", (scroll) => { - const group = Object.assign(new ScrollElement(null, true), { - scrollHeight: 300, - ...scroll, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); - }); - - it.each([100, 300])( - "consumes scrolling in a nested result with a group content height of %i", - (scrollHeight) => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 0.25, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); - }, - ); - - it("releases upward navigation when the group and nested result are both at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "scroll", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }); - - it("ignores targets outside a tool group and non-element targets", () => { - const outside = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); - expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); - expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); - }); - - it("does not consume scrolling from an ancestor beyond the tool group", () => { - const timeline = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - const group = new ScrollElement(timeline, true); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each(["hidden", "clip", "visible"])( - "ignores a non-scrollable child with overflow-y %s", - (overflowY) => { - const group = new ScrollElement(null, true); - const result = Object.assign(new ScrollElement(group), { - overflowY, - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }, - ); - - it("does not consume programmatic scrolling on an overflow-hidden group", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "hidden", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 928d99c549a6..528024de4393 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -239,22 +239,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } -export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { - const elementTarget = target instanceof Element ? target : null; - const group = elementTarget?.closest("[data-tool-group-scroll]"); - if (!group) return false; - - // A nested result or the group itself can consume an upward scroll. - for (let element = elementTarget; element; element = element.parentElement) { - if (element.scrollTop > 0) { - const overflowY = getComputedStyle(element).overflowY; - if (overflowY === "auto" || overflowY === "scroll") return true; - } - if (element === group) break; - } - return false; -} - export { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 23fb998f4645..66f2d7f5ab78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { isChatGptUsageLimitError } from "@t3tools/shared/usageLimits"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -324,12 +325,13 @@ import { terminalContextReference, } from "../lib/composerContextRecords"; import { - isQueuedMessageDue, latestCompletedToolActivityId, type QueuedComposerMessage, + type QueuedMessageSendSettings, useQueuedMessages, useQueuedMessageStore, } from "../queuedMessageStore"; +import { sendQueuedMessage } from "./chat/sendQueuedMessage"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; @@ -366,6 +368,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; +import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -475,7 +478,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; @@ -653,6 +655,8 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="switch"]', '[role="tab"]', ].join(","); +// Popups match only while open or closing: some stay mounted when closed, +// such as the chat header actions menu. const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ '[role="dialog"][aria-modal="true"]', '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', @@ -660,11 +664,11 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', + '[data-slot="menu-popup"]:is([data-open],[data-ending-style])', + '[data-slot="select-popup"]:is([data-open],[data-ending-style])', + '[data-slot="popover-popup"]:is([data-open],[data-ending-style])', + '[data-slot="combobox-popup"]:is([data-open],[data-ending-style])', + '[data-slot="autocomplete-popup"]:is([data-open],[data-ending-style])', ].join(","); type EnvironmentUnavailableState = { @@ -930,7 +934,14 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + // Hidden drawers stay mounted (see MAX_HIDDEN_MOUNTED_TERMINAL_THREADS), so they read only + // the shell: a detail subscription would keep each hidden thread's history in memory. The + // active drawer shares ChatView's detail, which also covers archived threads (no shell). + const activeServerThread = useThread(active ? threadRef : null, { + waitForShell: draftThread !== null, + }); + const serverThreadShell = useThreadShell(threadRef); + const serverThread = activeServerThread ?? serverThreadShell; const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -1235,7 +1246,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra "grid shrink-0 overflow-clip", active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden", active && - "[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out", + "[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out", active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!", )} > @@ -2736,68 +2747,40 @@ export function ChatViewContent(props: ChatViewProps) { unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = - environmentReconnecting && - (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); + environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - // Prioritize live connection progress among the notices. - priority: "urgent", - icon: ( -