diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index 7a6a1aa122a70..e0ef427bd1d36 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -1,5 +1,10 @@ name: Setup CI dependencies description: Install Vite+ and the workspace dependencies from cache. +inputs: + task-cache-read-token: + description: Read-only credential for the private maintainer task cache + required: false + default: "" runs: using: composite steps: @@ -65,3 +70,20 @@ runs: - name: Install dependencies shell: bash run: vp install + + - name: Enable shared task cache + if: >- + github.repository == 'pingdotgg/t3code' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && + hashFiles('.task-cache.json') != '' && + inputs.task-cache-read-token != '' + shell: bash + env: + T3_TASK_CACHE_READ_TOKEN: ${{ inputs.task-cache-read-token }} + run: | + node infra/task-cache/scripts/install-ci.ts + if [[ ( "$GITHUB_EVENT_NAME" == pull_request && "$GITHUB_BASE_REF" == main ) || ( "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == refs/heads/main ) ]]; then + echo 'VP_REMOTE_CACHE=read-write' >> "$GITHUB_ENV" + else + echo 'VP_REMOTE_CACHE=read' >> "$GITHUB_ENV" + fi diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index d7ebb1a0790cd..2ff10b84fd3a9 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -1,21 +1,10 @@ -// Decides which CI jobs a change needs. Prints `key=value` lines for -// $GITHUB_OUTPUT; a job runs unless its key is "false". -// -// node ci-changes.cjs --paths gate by the listed paths -// node ci-changes.cjs --paths --full run every Linux job, gate only the macOS lint -// node ci-changes.cjs changed paths unknown: run everything -// -// scripts/check-changed.ts requires the same decisions so local runs match CI. -// -// Every changed path maps to workspace packages, which then pull in their -// dependents from package.json. A path this file does not recognize runs -// everything, so a new root file or directory can only cost minutes. +// Unknown paths run every job; recognized packages pull in their dependents. +// scripts/check-changed.ts shares these decisions so local checks match CI. const fs = require("node:fs"); const path = require("node:path"); const root = path.resolve(__dirname, "../.."); -// Never read by a build, typecheck, or test. Lint still formats them. const INERT = [ /^docs\//, /^\.(agents|cursor|vscode|devcontainer|macroscope|repos)\//, @@ -23,15 +12,13 @@ const INERT = [ /^LICENSE$/, ]; -// Run everything: the root vite.config.ts loads the first as setup for every -// package's tests, and the vendored mobile tarballs are installed dependencies. +// Shared test setup and vendored mobile dependencies affect every package. const RUNS_EVERYTHING = [ /^packages\/shared\/src\/(HostProcess|testing\/longTempDir)\.ts$/, /^apps\/mobile\/deps\//, ]; -// Paths outside a package, or files inside one that other packages read -// through relative imports rather than a package.json dependency. +// Includes cross-package reads absent from package.json dependencies. const EXTRA_OWNERS = [ // Vite configs and tsconfigs of these apps import scripts/lib directly. [/^scripts\/lib\//, ["@t3tools/desktop", "@t3tools/mobile", "@t3tools/web", "t3"]], @@ -43,9 +30,7 @@ const EXTRA_OWNERS = [ ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; -// Tests that read another package's files without declaring it. The scripts -// tests read app manifests, assets, and sources (cursor-sdk-packaging, -// third-party-licenses, brand-assets, build-desktop-artifact). +// These tests read other packages without declaring dependencies. const TEST_ONLY_DEPS = { "@t3tools/provider-pi": ["@t3tools/client-runtime"], "@t3tools/scripts": [ @@ -56,17 +41,12 @@ const TEST_ONLY_DEPS = { "@t3tools/provider-cursor", ], }; -// Declared dependencies that only feed a build step: the server bundles the -// built web app (apps/server/vite.config.ts) but never imports it. +// The server bundles the web build without importing it. const BUILD_ONLY_DEPS = { t3: ["@t3tools/web"] }; -// Sources scripts/mobile-native-static-check.ts lints, plus the tool and rule -// configuration that decides how it lints them, plus the root package.json -// that defines the lint:mobile command. const MOBILE_NATIVE = /^apps\/mobile\/.*\.(swift|kt|kts)$|^apps\/mobile\/(\.swiftlint\.yml|detekt\.yml|\.editorconfig|Brewfile)$|^scripts\/mobile-native-static-check\.ts$|^package\.json$|^\.github\/workflows\/ci\.yml$|^\.github\/scripts\/ci-changes\.cjs$|^\.github\/actions\/ci-changes\//; -// Jobs that test every package except these, which have jobs of their own. const OWN_TEST_JOB = new Set(["t3", "@t3tools/web", "@t3tools/monorepo"]); function readWorkspace() { @@ -108,8 +88,7 @@ function readWorkspace() { return packages; } -// Returns the packages a path touches, [] for an inert path, or null when the -// path is not understood and everything must run. +// [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { if (INERT.some((pattern) => pattern.test(file))) return []; // A manifest can change dependencies or versions other packages read. @@ -148,7 +127,6 @@ function decide(files, packages, full) { if (everything) { affected = all; } else { - // Add dependents until nothing changes. let grew = true; while (grew) { grew = false; diff --git a/.github/scripts/mirror-play-apk.cjs b/.github/scripts/mirror-play-apk.cjs index 90b368b2aa036..d017fc907f124 100644 --- a/.github/scripts/mirror-play-apk.cjs +++ b/.github/scripts/mirror-play-apk.cjs @@ -1,7 +1,4 @@ -// Publishes closed-testing betas, or downloads the internal APK for a normal -// nightly release. Play generates and signs the APK from the -// uploaded bundle with the app signing key, so it carries the same signature -// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml. +// Play signs generated APKs with the app signing key, matching Play Store installs. const { execFileSync } = require("node:child_process"); const crypto = require("node:crypto"); const fs = require("node:fs"); @@ -50,10 +47,7 @@ async function play(token, method, url) { return response; } -// Highest version code the track serves, with its release name. Play names a -// release after the bundle's versionName unless the uploader sets one. This -// endpoint reads without opening an edit; an edit would invalidate any open -// EAS Submit edit made with the same service account. +// Opening an edit would invalidate any EAS Submit edit using the same service account. async function newestTrackBuild(token, appUrl, trackName) { const { releases = [] } = await ( await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`) @@ -82,8 +76,7 @@ function readRelease(tag) { } } -// Download to a temporary directory so a failed request cannot leave a partial -// APK among the release assets. +// Stage downloads so failed requests cannot leave partial release assets. async function downloadApk({ token, appUrl, build, apkName }) { const generated = await ( await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`) diff --git a/.github/scripts/stage-preview-bundle.py b/.github/scripts/stage-preview-bundle.py index 0b3a53285916b..1730ee5c8d752 100644 --- a/.github/scripts/stage-preview-bundle.py +++ b/.github/scripts/stage-preview-bundle.py @@ -12,8 +12,7 @@ "server/dist/client/index.html", "desktop/dist-electron/main.cjs", } -# The current bundle is about 32 MiB compressed. Bound extraction on the -# trusted runner even when the PR replaces the uploader entirely. +# Bound trusted-runner extraction even when a PR replaces the uploader. MAX_ARCHIVE_BYTES = 512 * 1024 * 1024 MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024 MAX_ENTRIES = 50_000 @@ -58,9 +57,7 @@ def stage_bundle(archive: Path, destination: Path): files.add(name) if not REQUIRED_FILES <= files: raise ValueError("Preview bundle is missing required entry points") - # Validate all names before writing anything. This is a fresh directory - # outside the checkout; neither pre-existing links nor trusted files - # can be followed or overwritten. ZIP permissions are never restored. + # Validate before writing into a fresh directory so existing links/files cannot be followed or overwritten. destination.mkdir(parents=True, exist_ok=False) for entry in entries: target = destination / entry.filename diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 5ed6a6ff498a9..ca57769ed490c 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -294,8 +294,7 @@ async function resolve({ github, context, core }) { const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); const sourceArtifact = findResultArtifact(sourceArtifacts); - // CI requires the artifact whenever the server tests run, so a passing run - // without one skipped them because the PR cannot affect the server. + // Successful server tests require this artifact; its absence means CI skipped them. if (!sourceArtifact && source.conclusion === "success") { core.info("CI skipped the server tests for this change; nothing to report."); core.setOutput("publish", "false"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1d22052c2f9..8e2af83cc5af1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: # file, docs included. The `Check` job at the end of this file passes only # when every other job passes or was skipped by `changes`. lint: + permissions: + contents: read + id-token: write name: Lint runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 @@ -82,6 +85,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -111,6 +116,9 @@ jobs: run: vp run skills:check build: + permissions: + contents: read + id-token: write name: Build and Typecheck needs: changes if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }} @@ -142,6 +150,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -200,6 +210,9 @@ jobs: # dependency layers. The concurrency limit stays at the default 4 so peak load # per runner is unchanged. test: + permissions: + contents: read + id-token: write name: Test needs: changes if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }} @@ -240,6 +253,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # electron's install.js (run by ensure:electron) reads this cache before it # downloads from GitHub. The key changes only with the Electron version. @@ -281,6 +296,9 @@ jobs: # started last and ran alone for about 40s after everything else finished. # release.yml mirrors this test, test_web, and test_server split. test_web: + permissions: + contents: read + id-token: write name: Test Web needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} @@ -298,6 +316,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # The browser project runs React tests in headless Chromium. Same cache key as # test_server, so both jobs share one downloaded browser. @@ -336,6 +356,9 @@ jobs: # The shards are the longest jobs, so they gate themselves instead of waiting # ~15s for `changes`: a shard the change cannot affect stops after checkout. test_server: + permissions: + contents: read + id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 @@ -362,6 +385,8 @@ jobs: - name: Setup Vite+ if: steps.changes.outputs.test_server != 'false' uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` @@ -498,6 +523,9 @@ jobs: run: vp run lint:mobile release_smoke: + permissions: + contents: read + id-token: write name: Release Smoke needs: changes if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }} @@ -515,6 +543,8 @@ jobs: - name: Setup Vite+ uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a80220c28a87..d1045a239c876 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata @@ -245,6 +247,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -275,6 +279,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron @@ -309,6 +315,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} - name: Resolve Playwright version id: playwright_version @@ -359,6 +367,8 @@ jobs: - name: Setup dependencies uses: ./.github/actions/setup-ci-deps + with: + task-cache-read-token: ${{ secrets.T3_TASK_CACHE_READ_TOKEN }} # No Electron setup here: `t3` (apps/server) has no Electron dependency # and none of its tests touch the runtime. Only the non-server `test` diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index e2881f21db746..9abe12fa3db3b 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -257,3 +257,27 @@ ENV/** # (see scripts/lib/reference-repos.ts). Nothing imports from them; findings # there belong upstream. .repos/** + +# Generated code. Review its source (the script, GraphQL document, schema +# definition, or theme) instead; review agents can still read these on demand. +**/*.generated.ts +**/*.graphql.ts +apps/server/schema/expo.graphql +packages/source-control-github/schema/github.graphql +apps/mobile/generated-uniwind-* +apps/mobile/clerk-theme.json +apps/server/src/testUtils/shardWeights.json +infra/relay/migrations/**/snapshot.json + +# Provider transcripts recorded from real CLI runs for the orchestration replay +# tests. Each scenario's input.ts and *_output.ts assertions stay reviewed. +**/*.ndjson + +# Ghostty build output vendored from upstream. +apps/mobile/modules/t3-terminal/Vendor/** +native/libghostty-vt/include/** + +# @pierre/highlights compiled to C by wasm2c, plus the wasm2c runtime and simde +# it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review +# that script and cpp/t3_highlights.c instead. +apps/mobile/modules/t3-highlights/Vendor/** diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index e79d0e71dc863..a7b10f64404a6 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -1,8 +1,5 @@ -// Windows WSL dev mode runs the server under Linux from the same node_modules -// as the Windows client, so Windows installs also need Linux x64 glibc native -// modules. Other platforms install only their own binaries; setting this -// everywhere added ~2.2 GB of foreign binaries to every macOS install. -// Release scripts stage their own installs with explicit architectures. +// Windows installs need Linux x64 glibc binaries because WSL shares node_modules. +// Keep other platforms native; release scripts set their own architectures. module.exports = { hooks: { // `typescript` is @tsc-rs/typescript (the catalog entry). Its npm version (0.8.x) is outside diff --git a/.task-cache.json b/.task-cache.json new file mode 100644 index 0000000000000..0e62827f9d64e --- /dev/null +++ b/.task-cache.json @@ -0,0 +1,3 @@ +{ + "endpoint": "https://t3-task-cache.pinglabs.workers.dev/projects/t3code" +} diff --git a/AGENTS.md b/AGENTS.md index b4f6752bde875..00ed1601e2f9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ Most code changes do not need an internal documentation change. Agents can read - `docs/internals/` is for architectural decisions and their reasons, constraints that span components, and implementation traps that are hard to discover from the source. Before adding a paragraph, ask what a maintainer would get wrong without it. If reading the relevant code answers the question, leave it out. - Do not document every feature, enumerate fields or methods, narrate control flow, maintain file catalogs, or append PR summaries. Types, tests, and code already record the implementation. The glossary defines shared vocabulary; it is not a feature index. -- Keep a local implementation explanation in a nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. +- A local reason the code can't show goes in a short nearby code comment. Use an internal doc when the reasoning crosses boundaries or needs context the code cannot carry well. Link to the relevant source instead of copying it. - When a documented decision or constraint changes, rewrite or remove the affected text. Do not append another account of the new behavior. A new internal page needs a distinct, durable reason to exist. - `docs/user/` helps users accomplish tasks. Give each major feature a concise section explaining what it does, how to start, and anything unintuitive. A settings path is useful; descriptions of visible buttons, icons, layouts, animations, or every UI state are not. Before adding text, ask what task or decision it helps the user with. - Keep user docs in the shipped product's voice, without implementation details or contributor tooling. Update the relevant feature section when how to use it changes. A UI tweak does not need a documentation entry, and a new control does not need its own page. @@ -202,7 +202,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. -- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. +- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. - Mock UI from the app itself. A requested HTML mock, mockup, or gallery of a T3 Code change is built from the web app's real components with `vp run render-mock`, never hand-written CSS that imitates them. See the `mock-t3-feature` skill. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 0fb4377f07be0..e433dc498cada 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -372,9 +372,7 @@ function buildMacLauncher(electronBinaryPath) { JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { if (isDevelopment) { - // The launcher also handles protocol activations outside the dev runner, - // so refresh its fallback environment on every launch. Never let a value - // captured by an older parent app override the live dev-runner environment. + // Protocol activations bypass the dev runner, so refresh the fallback environment on every launch. writeDevelopmentEnvironmentScript(); if (writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath)) { signMacLauncherBundle(targetAppBundlePath); @@ -385,10 +383,7 @@ function buildMacLauncher(electronBinaryPath) { } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); - // verbatimSymlinks keeps the framework's relative symlinks intact - // (e.g. Resources -> Versions/Current/Resources). Without it cpSync - // rewrites them to absolute paths into node_modules, which escape the - // bundle and crash sandboxed helper processes (icudtl.dat not found). + // Without verbatimSymlinks, cpSync makes framework symlinks point outside the bundle and crashes helpers. NodeFS.cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true, verbatimSymlinks: true, @@ -400,10 +395,7 @@ function buildMacLauncher(electronBinaryPath) { ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - // Launching the node_modules copy makes macOS associate the process (and Dock label) with - // Electron.app even though this bundle's Info.plist has the T3 Code name. Its conventional - // executable name also keeps Electron's default-app runtime in development mode instead of - // making app.isPackaged report true. + // Electron.app's executable name keeps default-app mode active without making app.isPackaged true. writeDevelopmentEnvironmentScript(); writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 9602ede4ec278..0ceaa0b71cf0d 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -342,11 +342,7 @@ const scopedProgram = Effect.scoped( const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; yield* Effect.addFinalizer(() => - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. + // Quit can outrun layer finalizers; stop pooled backends here so they receive SIGTERM before OS teardown. stopAllPoolInstances().pipe( Effect.ensuring(rendererHistory.shutdown), Effect.ensuring(shutdown.markComplete), diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 5e61c377b8dc7..8d16dbd3d315c 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -195,10 +195,7 @@ export async function startDesktopAppControlServer(input: { server.listen(address); }); - // Closing a Unix socket server unlinks the path it was bound to, even when - // another app's socket lives there now. Bind a staging path and move it onto - // the address instead, so a later close only unlinks the staging path, which - // is already gone. + // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first. const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); @@ -222,7 +219,6 @@ export async function startDesktopAppControlServer(input: { let server: NodeNet.Server; let inode: number | null = null; if (input.directory === null) { - // Named pipes close with the app that owns them, so no other app can remove this one. server = await listen(input.address); } else { await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); @@ -231,10 +227,10 @@ export async function startDesktopAppControlServer(input: { let closed = false; const reclaimOnce = async () => { - // Never replace a socket that exists, so two apps cannot trade the path back and forth. + // Replacing an existing socket would let two apps trade the path indefinitely. if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { - // A watch follows the directory's inode, so a recreated directory needs a new one. + // Directory watches follow inodes, so recreate the watch after directory replacement. watchDirectory(input.directory); } const next = await bindUnix(input.directory, "claim-free").catch( @@ -278,7 +274,7 @@ export async function startDesktopAppControlServer(input: { close: async () => { if (closed) return; closed = true; - // A running reclaim can replace the watcher, so close the watcher after it. + // Reclaim can replace the watcher; close it afterward. await pendingReclaim; watcher?.close(); for (const socket of sockets) socket.destroy(); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64c..5fd6983c66682 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -38,7 +38,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -91,9 +91,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - // Electron removes spaces from this name to build the native User-Agent - // product token, but leaves parentheses intact. Keep the runtime name valid - // without rewriting preview sessions (which breaks Turnstile, #7110). + // Electron strips spaces but keeps parentheses in its User-Agent token, breaking Turnstile in preview sessions. yield* electronApp.setName( `${environment.branding.baseName} ${environment.branding.stageLabel}`, ); @@ -107,9 +105,7 @@ export const make = Effect.gen(function* () { yield* electronApp.setAppUserModelId(environment.appUserModelId); } - // Unpackaged runs only. A packaged bundle already carries its icon in - // Info.plist, so setting the dock tile again changes nothing except to - // overwrite a custom icon the user attached to the app themselves. + // Packaged bundles carry their icon in Info.plist; setting it again overwrites the user's custom icon. if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 505b48194cf7c..3e0d753d1bfba 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -95,7 +95,7 @@ function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const electronApp = yield* ElectronApp.ElectronApp; @@ -105,14 +105,12 @@ export const make = Effect.gen(function* () { const userDataPath = yield* DesktopUserData.resolveUserDataPath(environment); yield* electronApp.setPath("userData", userDataPath); - // T3 owns the profile-scoped lock on every platform; Clerk skips it on macOS. - // Quit is asynchronous, so interrupt before the application runtime is built. + // Clerk skips the profile lock on macOS. Interrupt before runtime construction because quit is asynchronous. if (!(yield* electronApp.requestSingleInstanceLock)) { yield* electronApp.quit; return yield* Effect.interrupt; } - // Retain launch arguments while asynchronous startup builds the application runtime. const pendingSecondInstances: Array = []; let handleSecondInstance = (argv: readonly string[]) => { pendingSecondInstances.push(argv); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index c9a79a81d3b55..db56aa3c83b03 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -83,7 +83,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -// The machine's own PATH may already hold a t3; each test sets the PATH it means. const machinePath = process.env.PATH; beforeEach(() => { process.env.PATH = ""; diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 294f8a8b984e7..95d844e602124 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -28,14 +28,8 @@ const pathEntries = (value: string | undefined, separator: string) => const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); -/** - * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping - * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for - * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` - * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it - * opens see the change. A failed read exits nonzero rather than reading as - * empty, so a write never replaces the user's PATH wholesale. - */ +// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE. +// Failed reads must exit nonzero to prevent replacing PATH wholesale. const WINDOWS_USER_PATH_SCRIPT = ` $ErrorActionPreference = 'Stop' if ($env:T3_SET -eq '1') { @@ -64,7 +58,7 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e043b4d16d8de..50fcb08d9c64a 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -81,7 +81,6 @@ describe("renderCliShim", () => { executable: NodePath.join(root, "app"), entry: "/bin.mjs", }); - // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index a9d9f04c4e829..3d4b94abff462 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,8 +6,7 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// An AppImage mounts somewhere new each run, so its launcher mounts the -// AppImage itself instead of pointing into it. +// AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; @@ -26,12 +25,7 @@ export type CliShimTarget = | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; -/** - * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which - * reads the entry from inside the asar archive. The launcher's own path and the - * app's T3 home are written in, so the command the server shows is absolute and - * `sudo`, which clears the environment, still runs against this install's home. - */ +/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */ export const renderCliShim = (input: { readonly target: CliShimTarget; readonly shimPath: string; @@ -48,9 +42,7 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // Only a non-ASCII path needs the console switched to UTF-8 to read it; a - // Ctrl-C that ends the batch skips the restore, so the switch is left out - // for the common all-ASCII install. + // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -97,8 +89,7 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The AppImage runtime prints its mount point, then stays mounted until - // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. "work=$(mktemp -d) || exit 1", '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 773ea24509cf1..11f975a6b86af 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -55,6 +55,7 @@ export const DesktopConfig = Config.all({ otlpProtocol: Config.schema(OtlpProtocol, "T3CODE_OTLP_PROTOCOL").pipe( Config.withDefault("http/json"), ), + desktopLauncherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"), appImagePath: trimmedString("APPIMAGE"), disableAutoUpdate: optionalBoolean("T3CODE_DISABLE_AUTO_UPDATE"), mockUpdates: optionalBoolean("T3CODE_DESKTOP_MOCK_UPDATES"), diff --git a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts index 0015e642672c0..00a355bf49617 100644 --- a/apps/desktop/src/app/DesktopEarlyElectronStartup.ts +++ b/apps/desktop/src/app/DesktopEarlyElectronStartup.ts @@ -86,8 +86,7 @@ const WAYLAND_COLOR_MANAGER_FEATURE = "WaylandWpColorManagerV1"; const featureListNames = (value: string | null): ReadonlyArray => (value ?? "").split(",").flatMap((entry) => entry.trim().match(/^\*?([^<:]+)/)?.[1] ?? []); -// A stale HYPRLAND_INSTANCE_SIGNATURE is often inherited into other desktops, so only -// trust the session's desktop names. +// HYPRLAND_INSTANCE_SIGNATURE can be stale; trust only the session's desktop names. const isHyprlandSession = (env: NodeJS.ProcessEnv): boolean => (trimNonEmpty(env.XDG_CURRENT_DESKTOP) ?? trimNonEmpty(env.XDG_SESSION_DESKTOP) ?? "") .split(":") @@ -103,12 +102,8 @@ const HyprctlVersionJson = fromLenientJson( ); const decodeHyprctlVersionJson = Schema.decodeSync(HyprctlVersionJson); -// Hyprland 0.48.0 added wp-color-manager-v1 (hyprwm/Hyprland#9444) advertising -// FEATURE_SET_PRIMARIES while rejecting set_primaries outside debug mode, which Chromium -// treats as a fatal protocol error. 0.51.0 accepts them (hyprwm/Hyprland#11499). -// Only stock builds of these releases count. Matching the commit as well as the tag -// keeps later development builds that still report an old VERSION and tag (Nix -// synthesizes `v${VERSION}`) on the default. +// Hyprland 0.48.0 rejects advertised set_primaries (hyprwm/Hyprland#9444); 0.51.0 accepts it (hyprwm/Hyprland#11499). +// Check commits because dev and Nix builds can retain release versions and tags. const AFFECTED_HYPRLAND_RELEASE_COMMITS: ReadonlyMap = new Map([ ["0.48.0", "5ee35f914f921e5696030698e74fb5566a804768"], ["0.48.1", "29e2e59fdbab8ed2cc23a20e3c6043d5decb5cdc"], diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index ecf08255feb3f..1066198b8f610 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -77,6 +77,7 @@ export class DesktopEnvironment extends Context.Service< readonly linuxDesktopEntryName: string; readonly linuxWmClass: string; readonly linuxApplicationsDir: string; + readonly desktopLauncherPath: Option.Option; readonly appImagePath: Option.Option; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; @@ -233,6 +234,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxDesktopEntryName: resolveLinuxDesktopEntryName(isDevelopment), linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", linuxApplicationsDir, + desktopLauncherPath: config.desktopLauncherPath, appImagePath: config.appImagePath, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ diff --git a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts index 122aa3393f9cd..90043d9398a2c 100644 --- a/apps/desktop/src/app/DesktopLegacyLocalStorage.ts +++ b/apps/desktop/src/app/DesktopLegacyLocalStorage.ts @@ -10,10 +10,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { readChromiumLocalStorage } from "./chromiumLocalStorage.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -/** - * The V1 profile is only read, never opened by Chromium, so this works while V1 - * is still running. - */ +/** Reads the V1 profile without opening it in Chromium, even while V1 runs. */ export class DesktopLegacyLocalStorage extends Context.Service< DesktopLegacyLocalStorage, { diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 7512d2bc1741d..93347949e00fa 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -208,7 +208,7 @@ function quitFromSignal( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = DesktopLifecycle.of({ relaunch: Effect.fn("desktop.lifecycle.relaunch")(function* (reason) { const electronApp = yield* ElectronApp.ElectronApp; diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 854f43c5623c1..feedbbd580074 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -31,6 +31,7 @@ const makeEnvironment = (path: Path.Path, overrides: Record = { linuxDesktopEntryName: "com.t3tools.T3Code.desktop", linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", + desktopLauncherPath: Option.none(), appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), path, ...overrides, @@ -228,6 +229,18 @@ describe("DesktopLinuxUrlHandler", () => { }); }); + it.effect("launches a distribution package through its configured launcher", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { + environment: { desktopLauncherPath: Option.some("/opt/T3 Code/t3code") }, + }); + + assert.include(recorded.files[0]?.content, 'Exec="/opt/T3 Code/t3code" %U'); + }); + }); + it.effect("does not rewrite the pre-ready entry while the portal can be reading it", () => { const recorded = emptyRecording(); @@ -457,9 +470,7 @@ const themeIconFixture = (source: Uint8Array) => const hicolorDir = path.join(root, "icons", "hicolor"); const appsDir = path.join(hicolorDir, "256x256", "apps"); const cachePath = path.join(hicolorDir, "icon-theme.cache"); - // A fixed time in the past, in whole seconds as fs.utimes expects. const pastSeconds = 1_600_000_000; - // An existing cache that predates any installed icon. const seedCache = Effect.gen(function* () { yield* fileSystem.makeDirectory(appsDir, { recursive: true }); yield* fileSystem.writeFile(cachePath, new Uint8Array([0])); @@ -547,7 +558,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { commands: [], onIconCacheRefresh: fixture.rewriteCache, }; - // A cache stamped later than the new icons, as after a clock correction. const future = fixture.pastSeconds + 10 * 365 * 24 * 60 * 60; yield* fileSystem.utimes(fixture.cachePath, future, future); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e03418964e33b..dd38acbc3894e 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -12,12 +12,7 @@ import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// Linux ships as an AppImage, so the .desktop entry users end up with is -// created by whatever integration tool they use (AppImageLauncher names it -// appimagekit_-….desktop) and its filename is not under our control. -// Electron's app.setAsDefaultProtocolClient resolves the desktop id from -// setDesktopName, which cannot match those files — so the browser keeps -// prompting "Choose an application" for every OAuth callback. +// AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -62,9 +57,7 @@ const escapeDesktopEntryString = (value: string): string => .replaceAll("\r", "\\r") .replaceAll("\t", "\\t"); -// Exec values are unescaped twice by implementations: first the general -// string-value rules, then the Exec quoting rules — so writing composes the -// layers in reverse. +// Exec values undergo general unescaping then Exec unquoting; escape in reverse order. export function escapeDesktopEntryExecArgument(value: string): string { const quoted = value .replaceAll("\\", () => "\\\\") @@ -103,9 +96,7 @@ export function renderUrlHandlerDesktopEntry(input: { ].join("\n"); } -// Some docks and bars look the Wayland app id up in the icon theme when no -// visible desktop entry matches the window, for example an AppImage run without -// integration. Theme lookups are case-sensitive, so install both spellings. +// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive. const LINUX_THEME_ICON_SIZE = "256x256"; function linuxThemeIconNames(desktopEntryName: string): readonly string[] { @@ -123,7 +114,7 @@ export class DesktopLinuxUrlHandler extends Context.Service< } >()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -141,17 +132,21 @@ export const make = Effect.gen(function* () { const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps"); const writeDesktopEntry = Effect.gen(function* () { + // Distribution packages that run on a shared Electron set + // T3CODE_DESKTOP_LAUNCHER_PATH, because process.execPath is that Electron. // Inside the mounted AppImage, process.execPath points at a transient // /tmp/.mount_* path — the handler must launch the AppImage itself. - const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); + const execTarget = environment.desktopLauncherPath.pipe( + Option.orElse(() => environment.appImagePath), + Option.getOrElse(() => process.execPath), + ); const content = renderUrlHandlerDesktopEntry({ 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. + // Avoid truncating the pre-ready entry while the portal may be reading it. const existing = yield* fileSystem .readFileString(desktopEntryPath) .pipe(Effect.orElseSucceed(() => null)); @@ -235,10 +230,8 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFile(target, icon); changed = true; } - // Only keep an existing cache current. Creating one would leave GTK reading - // a cache that other tools installing user icons do not update. An icon - // newer than the cache also retries a refresh that failed on an earlier - // launch. + // Creating a cache would leave GTK reading stale icons from other installers. + // A newer icon retries a refresh that failed on an earlier launch. const cachePath = environment.path.join(hicolorDir, "icon-theme.cache"); const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option); if (Option.isNone(cachedAt)) return; @@ -313,10 +306,7 @@ export const make = Effect.gen(function* () { yield* installThemeIcons; yield* updateDesktopDatabase.pipe( - // Some MIME implementations, including GIO, use mimeinfo.cache to verify - // that a desktop entry is associated with a scheme. Cache refresh is - // independently best-effort so a missing update-desktop-database executable - // does not prevent xdg-mime from recording the requested default. + // GIO verifies scheme associations through mimeinfo.cache. A missing cache updater must not block xdg-mime. Effect.catch((error) => logWarning("desktop MIME cache refresh failed", { applicationsDir: environment.linuxApplicationsDir, diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 55d200a159177..6baf2c4117f9d 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -428,8 +428,7 @@ const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { - // Primary keeps the historical "server-child.log" path so ops scripts - // and packaged-build log inspection still find it where it always lived. + // Keep the primary log path stable for ops scripts and packaged-build inspection. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } @@ -437,8 +436,7 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Cacheable by resolved file path so two ids that sanitize to the same filename -// share a single RotatingLogFileWriter (no race on currentSize tracking). +// IDs sharing a sanitized filename must share a writer to avoid currentSize races. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -609,16 +607,7 @@ const layerTelemetry = Layer.unwrap( }, }; - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the main process wants has to be named in this one call. - // Splitting the OTLP logger back out into a layer of its own silently - // drops either it or the console output. - // Swapping `Logger.tracerLogger` out for the OTLP logger matches the - // server: both reach a collector, but the tracer logger covers only - // messages logged inside a recorded span and files them under traces, - // while the OTLP logger carries every message as a log record stamped - // with its trace and span ids. Keeping both would export every in-span - // message twice. + // Logger.layer replaces the logger set. tracerLogger would duplicate OTLP in-span messages. const layerLogger = Logger.layer( endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 5af13f94413f5..6a7473e2f1b1a 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -32,9 +32,7 @@ const syncFs = (method: string, path: string, run: () => A) => }), }); -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = FileSystem.makeNoop({ exists: (path) => syncFs("exists", path, () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 143a6e5fd87c7..f2b176ee3761f 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -166,6 +166,28 @@ describe("DesktopPreReadyPlatform", () => { ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); + it.effect("points the early portal entry at a configured distribution launcher", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPIMAGE", "/Applications/current.AppImage"); + vi.stubEnv("T3CODE_DESKTOP_LAUNCHER_PATH", " /opt/T3 Code/t3code "); + getSwitchValueMock.mockReturnValue(""); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcess.Platform, "linux")), + ), + ); + const entry = writeFileSyncMock.mock.calls.find( + ([path]) => path === "/xdg/applications/com.t3tools.T3Code.desktop", + )?.[1]; + assert.include(entry, 'Exec="/opt/T3 Code/t3code" %U'); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + 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 58670439a5255..8bef7366e378a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -49,7 +49,7 @@ export class DesktopPreReadyElectronOptions extends Context.Service< } >()("@t3tools/desktop/app/DesktopPreReadyPlatform/DesktopPreReadyElectronOptions") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const homeDirectory = yield* HostProcess.HomeDirectory; @@ -109,15 +109,17 @@ export const make = Effect.gen(function* () { isDevelopment: linux.isDevelopment, appVersion: Electron.app.getVersion(), }).displayName, - execTarget: process.env.APPIMAGE?.trim() || process.execPath, + execTarget: + process.env.T3CODE_DESKTOP_LAUNCHER_PATH?.trim() || + process.env.APPIMAGE?.trim() || + process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); } catch {} - // Chromium caches its portal registration during startup. Set the identity - // before any asynchronous work can initialize it with Electron's default. + // Chromium caches portal identity; set it before any asynchronous initialization. Electron.app.setDesktopName(linux.linuxDesktopEntryName); Electron.app.commandLine.appendSwitch("class", linux.linuxWmClass); if (linux.passwordStore !== null && linuxPasswordStoreCommandLine === null) { @@ -146,8 +148,7 @@ export const make = Effect.gen(function* () { }); }).pipe(Effect.withSpan("desktop.electron.configureBeforeReady")); -// Keep Electron's strict pre-ready setup isolated so later runtime layers cannot -// observe app readiness before scheme privileges and command-line switches exist. +// Complete scheme privileges and switches before later layers can observe app readiness. export const layer = Layer.mergeAll( ElectronProtocol.layerSchemePrivileges, Layer.effect(DesktopPreReadyElectronOptions, make), diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index 98de2e91eb063..51beceb9e463f 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -4,12 +4,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -/** - * It reads the database files directly and never takes LevelDB's lock, so it - * works while another app version has the profile open. Live files come from - * the MANIFEST that CURRENT names, and the newest sequence number wins, which - * is how LevelDB itself resolves overwrites and deletions. - */ +/** Read without LevelDB's lock so another app can keep the profile open. */ const LOG_BLOCK_SIZE = 32 * 1024; const LOG_HEADER_SIZE = 7; @@ -145,11 +140,7 @@ function decompressSnappy(input: Uint8Array): Uint8Array { return output; } -/** - * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of - * its block, since a reused log keeps appending after a torn write, and a - * record cut off by the end of the file ends the log. - */ +/** Corruption skips the remaining block; a truncated final record ends the log, matching LevelDB recovery. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let pending: Uint8Array[] | null = null; @@ -422,9 +413,7 @@ export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage. }).pipe(wrap); } - // A flush while another process has the database open can move entries - // from a log we read into a table our MANIFEST copy predates. Fail so the - // caller retries rather than import a snapshot missing those entries. + // Concurrent flushes can move entries beyond this MANIFEST snapshot; detect changes so the caller retries. const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index dadbe8acc743b..06b4c6b59816d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -81,8 +81,7 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. +// Windows forwards these variables into WSL only through WSLENV. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -92,9 +91,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", - // Forwarded without a WSLENV flag, so the values arrive untranslated. The - // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs - // travel as variables to keep winning inside the distro as they do on Windows. + // Untranslated T3 URLs must override bootstrap endpoints inside WSL, as they do on Windows. "T3CODE_OTLP_TRACES_URL", "T3CODE_OTLP_METRICS_URL", "T3CODE_OTLP_LOGS_URL", @@ -145,10 +142,7 @@ const mergeWslEnv = ( const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); - // Preserve the user's WSLENV exactly as Windows handed it to us — empty - // "::" segments and duplicate entries are harmless no-ops to WSL and not - // ours to normalize — and only append the secrets we need to forward - // across the wsl.exe boundary. + // Preserve empty segments and duplicates in the user's WSLENV; only append required variables. const parts = [existing, ...additions].filter((part) => part.length > 0); return parts.length > 0 ? parts.join(":") : undefined; }; @@ -253,10 +247,7 @@ type WslPreflightRuntime = } | { readonly kind: "node-script"; - // Absolute path to the node binary the preflight validated after the - // shared remote resolver repaired PATH. The launch must use this exact - // path so it doesn't fall through to a different/old node than the one - // node-pty was probed with. + // Launch the exact Node path probed with node-pty, even if later PATH lookup would resolve another version. readonly nodePath: string; readonly linuxEntryPath: string; }; @@ -397,8 +388,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime supplies its own Node and node-pty. Provider PATH - // discovery must not require either dependency for runtime readiness. + // Staged runtimes bundle Node and node-pty; provider PATH discovery must not require them. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; @@ -438,10 +428,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f nodePtyOptions, ); if (!nodePtyResult.ok) { - // Substituting the staged verdict for a transient mounted failure would - // turn a retryable failure into a fatal one, ending the WSL attempt (and, - // in wsl-only mode, persisting Windows) before the slow /mnt path had a - // chance to answer and clear the bad cache. + // Only substitute a staged failure for a fatal mounted failure; transient /mnt failures must remain retryable. return stagedFailure && nodePtyResult.fatal ? failedStaged(stagedFailure) : failedNodePty(nodePtyResult); @@ -464,16 +451,12 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the -// signature we use to detect that mode and switch the renderer URL to loopback. +// In WSL2 mirrored mode, eth0 matches a host IP; use loopback for the renderer. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { - // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on - // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -520,7 +503,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // Failed Windows probes leave server hydration enabled for another attempt. shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, @@ -537,10 +519,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - // Packaged builds only, so a dev instance never shares the cache with the - // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, - // so the setting does not leak into the provider and terminal processes - // the backend starts. + // Packaged builds have a separate cache; --require prevents cache settings leaking to provider subprocesses. args: [ ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), environment.backendEntryPath, @@ -581,25 +560,18 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const fileSystem = yield* FileSystem.FileSystem; - // wslhost forwarding is unreliable on some Windows hosts: the desktop's - // readiness probe and the renderer's saved-env-style fetch both saw "Failed - // to fetch" when the backend only bound to 127.0.0.1 inside WSL. Binding to - // 0.0.0.0 plus advertising the WSL IP as the renderer-visible URL avoids that - // dependency. + // wslhost loopback forwarding is unreliable on some Windows hosts; advertise the WSL IP instead. const wslBindHost = "0.0.0.0"; const bootstrap = { mode: "desktop" as const, noBrowser: true, port: input.port, - // Omit t3Home so the Linux backend uses its own home dir instead of - // the Windows-side baseDir (which would be a /mnt/c path and share - // the SQLite file with the primary). + // Omit t3Home to keep the Linux backend from sharing the primary's Windows SQLite file. host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // PortSchema rejects 0, so when tailscale serve is disabled we still need a - // valid number in this slot. + // PortSchema rejects 0 even when Tailscale Serve is disabled. tailscaleServeEnabled: false, tailscaleServePort: 443, ...buildObservabilityFragment(input.observabilitySettings), @@ -640,9 +612,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl allowBuild: !environment.isPackaged, }); - // Every operation after preflight uses the same concrete distro. In - // default-tracking mode this closes the race where the system default - // changes between probing and spawning the backend. + // Pin the preflight distro so a default change cannot race backend startup. const runningDistro = preflight._tag === "Ready" ? preflight.runningDistro : null; const distroForConfig = runningDistro ?? input.distro; @@ -685,8 +655,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...forwardedEnv, ...(wslEnv !== undefined ? { WSLENV: wslEnv } : {}), }, - // env is already a complete process.env minus T3CODE_HOME; pass it - // verbatim instead of letting the spawner re-merge process.env on top. + // env already excludes T3CODE_HOME; merging process.env would restore it. extendEnv: false, bootstrap, bootstrapDelivery: "stdin" as const, @@ -695,8 +664,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...(runningDistro !== null ? { runningDistro } : {}), }; - // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. + // Explicit --dev-url keeps WSL state in dev/ rather than userdata/. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], @@ -716,10 +684,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; } - // The WSL server spawns commands its providers reference by name — `npm`/`npx` - // for provider updates, and the installed CLIs themselves (e.g. `codex`). Those - // live on the user's login-shell PATH, which `wsl.exe --exec` does NOT put on - // the process PATH, so `npm install -g ...` fails with NotFound. + // wsl.exe --exec omits the login-shell PATH needed to find provider CLIs and npm. const runtime = preflight.runtime; const launchPath = runtime.kind === "executable" @@ -747,7 +712,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl } satisfies DesktopBackendManager.DesktopBackendStartConfig; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; @@ -757,12 +722,7 @@ export const make = Effect.gen(function* () { const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; - // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. - // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can - // resolve concurrently; with a plain Ref both could observe None, generate - // distinct secrets, and one would overwrite the other — leaving the two - // backends deriving mismatched tokens and breaking the shared-token invariant - // the renderer relies on. + // Random generation yields; serialize it so concurrent backends cannot capture distinct bootstrap secrets. const secretRef = yield* SynchronizedRef.make(Option.none()); const getOrCreateBootstrapSecret = SynchronizedRef.modifyEffect(secretRef, (current) => Option.match(current, { @@ -833,18 +793,11 @@ export const make = Effect.gen(function* () { ); }); - // Dispatch happens at resolve time so toggling wsl-only between restarts - // is picked up on the next start cycle (the pool's primary instance is - // created once at layer init, but configResolve fires on each restart). + // Resolve on each restart so toggling wsl-only takes effect without recreating the pool. const describePrimary = Effect.gen(function* () { const persistedSettings = yield* settings.get; const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; - // Only honor wsl-only when WSL is actually usable. If the user - // persisted wsl-only but WSL has since become unavailable (wsl.exe - // removed, no distro), fall back to the Windows primary instead of - // looping forever on preflight failures: the Connections backend - // control is hidden while WSL is unavailable, so a stuck WSL primary - // would otherwise leave no in-app way back to Windows. + // When WSL is unavailable its controls are hidden; fall back to Windows to keep recovery possible. const useWsl = wslRequested && (yield* wslEnvironment.isAvailable); return { useWsl, wslRequested, distro: persistedSettings.wslDistro }; }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b4057f4d0194b..007f7369365a2 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -37,8 +37,7 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5; const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1); const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100); const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1); -// Same as the service launcher. A killed backend leaves its agents running, -// and a busy one needs a few seconds to stop every provider session. +// Match the service launcher's grace period so backends can stop provider sessions. const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5); const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5); const BACKEND_READINESS_PATH = "/.well-known/t3/environment"; @@ -63,9 +62,7 @@ export type DesktopBackendBootstrapDelivery = "fd3" | "stdin"; export interface DesktopBackendStartConfig extends BackendProcessContext { readonly args: ReadonlyArray; readonly env: Record; - // When true the spawner merges the desktop process.env on top of `env`; - // when false `env` is passed verbatim. WSL mode opts out so a leaking - // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + /** False passes env verbatim, preventing the Windows T3CODE_HOME from leaking into WSL. */ readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; @@ -234,11 +231,7 @@ export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; readonly configResolve: Effect.Effect; - // Receives the *resolved* httpBaseUrl of the run that just became - // ready. The window service uses this to decide what URL to load - // (the WSL backend reports its distro IP, the Windows backend reports - // 127.0.0.1). Splitting this off from configResolve avoids races - // between "fired onReady" and "currentConfig already advanced". + /** Receives the ready run's resolved URL; reading current config could race a subsequent restart. */ readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; readonly onPreflightFailed?: (failure: PreflightFailure) => Effect.Effect; @@ -431,16 +424,12 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( cwd: options.cwd, env: options.env, extendEnv: options.extendEnv, - // In Electron main, process.execPath points to the Electron binary. - // Run the child in Node mode so this backend process does not become a GUI app instance. stdin: options.bootstrapDelivery === "stdin" ? bootstrapStream : "ignore", stdout: options.captureOutput ? "pipe" : "inherit", stderr: options.captureOutput ? "pipe" : "inherit", killSignal: "SIGTERM", forceKillAfter: DEFAULT_BACKEND_TERMINATE_GRACE, - // wsl.exe drops additional file descriptors when forwarding to the Linux - // side, so the WSL spawn path delivers the bootstrap envelope via stdin - // (`--bootstrap-fd 0`) instead. + // wsl.exe drops extra file descriptors; deliver its bootstrap via stdin. ...(options.bootstrapDelivery === "fd3" ? { additionalFds } : {}), }); @@ -542,12 +531,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( ).pipe(Effect.forkScoped), ); } - // Probe readiness in a loop while the backend process is still alive instead - // of giving up after the first budget. A slow cold boot (the WSL bundle - // loading across /mnt/c, or a first launch right after an update) can exceed - // the initial readiness budget while the backend is about to come up moments - // later; a one-shot probe left the app stuck on "Connecting to WSL…" forever - // even though the backend kept running and became healthy. + // Cold WSL boots across /mnt/c can exceed one probe budget; retry while the backend is alive. const probeReadiness = Effect.fn("desktop.backendProcess.probeReadiness")(() => waitForHttpReady({ executablePath: options.executablePath, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index a8576a2b9e99e..0583e8df21705 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -96,8 +96,6 @@ describe("DesktopBackendPool", () => { const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - // A transient preflight failure leaves a restart fiber scheduled - // without needing a real child process. configResolve: Effect.succeed({ executablePath: "/electron", args: [], diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 135df79c63ada..7b2f685190761 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -106,16 +106,11 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - // Without this, instance scopes are orphaned: they only close via explicit unregister() calls, - // so on app shutdown the WSL backend child process gets hard-killed by the OS instead of - // receiving the graceful SIGTERM + grace period the instance's stop finalizer would otherwise - // run. + // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. const layerScope = yield* Scope.Scope; const factoryContext = yield* Effect.context(); - // Fatal configuration failures persist the Windows fallback. Bounded - // transport failures use an in-memory fallback for this launch so the app - // opens without overwriting the user's WSL preference. + // Persist fatal Windows fallbacks; transient transport failures must preserve the user's WSL preference. const handlePrimaryPreflightFailure = Effect.fn("desktop.backendPool.primaryPreflightFailed")( function* (failure: DesktopBackendManager.PreflightFailure) { const { reason, fatal } = failure; @@ -139,9 +134,8 @@ export const layer = Layer.effect( "WSL backend couldn't start", `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`, ); - // Clearing wslBackendEnabled alone would leave a stale wslOnly:true that silently re-traps - // the user in wsl-only mode the next time they enable WSL. If the persisted write fails, - // keep this process recoverable by applying the fallback to in-memory settings. + // Clear wslOnly too so enabling WSL cannot restore a failed mode. + // Keep the in-memory fallback if persistence fails. yield* appSettings.applyWslWindowsFallback.pipe( Effect.catch((error) => logBackendPoolWarning( @@ -158,14 +152,10 @@ export const layer = Layer.effect( const primary = yield* DesktopBackendManager.makeBackendInstance({ id: DesktopBackendManager.PRIMARY_INSTANCE_ID, - // Keep this lazy. The pool layer is initialized before startup loads - // persisted desktop settings, so resolving the primary label here would - // permanently capture DEFAULT_DESKTOP_SETTINGS and mislabel WSL-only - // primaries as Windows. + // Resolve lazily: startup loads persisted settings after pool initialization. label: configuration.resolvePrimaryLabel, configResolve: configuration.resolvePrimary, - // Window creation errors propagating out of handleBackendReady must not block the readiness - // callback (that would prevent restartAttempt from being reset), so we absorb them here. + // Absorb window errors so readiness can reset restartAttempt. onReady: (httpBaseUrl) => desktopWindow.handleBackendReady(httpBaseUrl).pipe( Effect.catch((error) => @@ -219,10 +209,7 @@ export const layer = Layer.effect( ] as const); } return Effect.gen(function* () { - // Provide the child scope after the captured factory context. That - // context also holds the pool layer's Scope, so providing it last - // would let it shadow the child scope and leave the instance's - // stop finalizer and restart fibers running after unregister. + // Provide the child Scope last so the captured pool Scope cannot shadow it and orphan stop/restart finalizers. const instanceScope = yield* Scope.fork(layerScope, "sequential"); const instance = yield* DesktopBackendManager.makeBackendInstance(spec).pipe( Scope.provide(instanceScope), diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 4d95dd075cf3e..6481bfd64c897 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -63,7 +63,7 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< } >()("@t3tools/desktop/backend/DesktopLocalEnvironmentAuth") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; @@ -85,9 +85,7 @@ export const make = Effect.gen(function* () { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } const config = configOption.value; - // A backend launched with the desktop secret accepts the current - // window's token, not the one frozen into its launch config; this - // exchange can run long after launch (e.g. after a suspend). + // Secret-authenticated backends accept the current window token; the launch token may be stale. const secret = config.bootstrap.desktopBootstrapSecret; const credential = secret === undefined diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f9a69692a2504..8d178c605c4a6 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -71,15 +71,7 @@ const isUsableLanIpv4Address = (address: string): boolean => !address.startsWith("169.254.") && !isTailscaleIpv4Address(address); -/** - * Bridges, tunnels and VM adapters whose private address other devices on the - * LAN usually cannot reach: macOS's Thunderbolt Bridge (`bridge0`, which can - * keep a manual address while nothing is plugged in) and Internet Sharing - * bridges, VPN tunnels, and container or VM networks. Their addresses are only - * advertised when no other interface has one. Host bridges such as `br0`, - * Proxmox's `vmbr0` and Hyper-V's `vEthernet` external switches often carry - * the LAN address, so they are not listed. - */ +// Private bridges and VM adapters are fallbacks; host bridges can carry the real LAN address. const HOST_ONLY_INTERFACE = /^(bridge\d|utun|ipsec|ppp|tun|tap|wg|docker|br-|veth|virbr|vmnet|vboxnet|VirtualBox|VMware)/; @@ -441,7 +433,7 @@ const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): bo previous.bindHost !== next.bindHost || previous.localHttpUrl !== next.localHttpUrl; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; @@ -449,9 +441,7 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); - // Each change reads the runtime state, persists settings, then writes the - // state back. Run them one at a time so a change never writes over another - // with values it read before that change persisted. + // Serialize read-persist-write changes so stale runtime state cannot overwrite another update. const changePermit = yield* Semaphore.make(1); // Key status by interface addresses so network changes bypass stale DNS and @@ -573,9 +563,7 @@ export const make = Effect.gen(function* () { customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. + // Spawning Mac App Store Tailscale can prompt for TCC access even when exposure is disabled. if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 044625a32b445..4b75afccdb5bc 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -33,8 +33,7 @@ function resolveTailscaleIpAdvertisedEndpoints(input: { for (const [interfaceName, interfaceAddresses] of Object.entries(input.networkInterfaces)) { if (!interfaceAddresses) continue; - // CGNAT space is shared with other VPNs. Native adapter identity also - // works when the CLI is missing or its cached status predates a connection. + // CGNAT is shared with other VPNs; adapter identity works without current CLI status. const isTailscaleInterface = /^tailscale(?:\d+|ipv[46])?$/iu.test(interfaceName) || interfaceAddresses.some( diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts index a8abdad9bbc60..f2524f246c664 100644 --- a/apps/desktop/src/boot.ts +++ b/apps/desktop/src/boot.ts @@ -1,4 +1,2 @@ -// Enables the compile cache before the main bundle loads, so the cache also -// covers main.cjs itself. require("./compileCache.cjs"); require("./main.cjs"); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts index da0fd66f5e582..abf8918df2af0 100644 --- a/apps/desktop/src/compileCache.ts +++ b/apps/desktop/src/compileCache.ts @@ -3,10 +3,8 @@ import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -// Linux uses the user's cache dir because /tmp is shared between users; the macOS and Windows -// temp dirs are already per user. Skipped under AppImage: it mounts the app at a new -// /tmp/.mount_* path each launch, and Node keys entries by path, so every launch would miss -// and leave another copy behind. +// Linux /tmp is shared; macOS and Windows temp dirs are per user. +// AppImage mounts change paths each launch, so Node's path-keyed cache would miss and accumulate copies. try { if (!process.env.APPIMAGE) { const cacheRoot = diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 0aae1148a4828..7190c70aedc21 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,12 +43,7 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; - /** - * The OS locale, read from the operating system rather than from Chromium's - * resolved application locale — the packaged app ships only the `en-US` - * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. - */ + /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; @@ -98,7 +93,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ @@ -127,9 +122,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. + // NSLocale returns en_GB-style tags; Intl requires hyphens. systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 25b555334e2eb..d2a72ba88fd68 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -127,7 +127,7 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const ownerWindowId = Option.match(input.owner, { diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 5834f139c8e8e..60b7eaef4c38b 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -70,8 +70,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } - // Header items are decorative section labels for the web fallback only — - // Electron's native menu has no equivalent affordance, so we skip them. + // Native menus cannot display the web fallback's decorative headers. if (sourceItem.header === true) { continue; } @@ -99,9 +98,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } -// Renderer positions arrive in CSS pixels; popup() expects window points, so -// page zoom must be factored in or menus drift proportionally to their -// distance from the window origin. +// popup() takes window points; renderer CSS coordinates must account for page zoom. const normalizePosition = ( position: Option.Option, zoomFactor: number, @@ -114,7 +111,7 @@ const normalizePosition = ( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 4fb42dfdc9f47..41c36acf2250a 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -75,7 +75,7 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] ).pipe(Effect.asVoid); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d6f476205f4c5..d84b6f360c89e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -78,11 +78,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "https://challenges.cloudflare.com", ]; - // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment - // origins are not known when this response policy is created, so restrict - // connections by the network schemes the client supports instead of by host. - // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + // User-configured environment origins are unknown here, so allow supported network schemes. + // GLTFLoader fetches embedded textures through blob URLs. const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ @@ -110,9 +107,7 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } -/** - * Must run synchronously during process bootstrap, before Electron emits `ready`. - */ +// Must run synchronously before Electron emits ready. function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { @@ -123,8 +118,7 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, - // Custom schemes skip Chromium's V8 code cache unless they opt in. - // Dev stays off: Vite serves changing code at stable URLs. + // Custom schemes must opt into V8 caching; dev URLs serve changing code. codeCache: true, }, }, @@ -246,7 +240,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const context = yield* Effect.context(); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index cd01613d92fd0..0395d20e9bd38 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -65,7 +65,7 @@ export class ElectronSafeStorage extends Context.Service< } >()("@t3tools/desktop/electron/ElectronSafeStorage") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index af4d4834b4fb4..9b93a378bab30 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -11,9 +11,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must -// reach the OS handler; every other non-web scheme stays blocked. +// Remote editor deep links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -68,7 +66,7 @@ export class ElectronShell extends Context.Service< } >()("@t3tools/desktop/electron/ElectronShell") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronShell.of({ openExternal: (rawUrl) => Option.match(parseSafeExternalUrl(rawUrl), { diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..a84eedda13b4e 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -28,7 +28,7 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), setSource: (theme) => diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4..397044932607b 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -80,7 +80,7 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronUpdater.of({ setFeedURL: (options) => Effect.suspend(() => { diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 019ba88085cf9..4163d4a429709 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -136,11 +136,10 @@ export class ElectronWindow extends Context.Service< } >()("@t3tools/desktop/electron/ElectronWindow") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; - // The focus worker loads a native accessibility module. Start it on the first - // capture reveal so users who never capture pay nothing at launch. + // Lazy-load native accessibility on capture reveal to avoid startup cost for other users. let windowsForegroundFocus: ReturnType | undefined; const ensureWindowsForegroundFocus = () => { windowsForegroundFocus ??= startWindowsForegroundFocusThread( diff --git a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts index 13f657e7812f8..a0c2fb27a5eac 100644 --- a/apps/desktop/src/ipc/methods/legacyLocalStorage.ts +++ b/apps/desktop/src/ipc/methods/legacyLocalStorage.ts @@ -22,8 +22,7 @@ export const completeLegacyLocalStorage = makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.legacyLocalStorage.complete")(function* (_, event) { const legacy = yield* DesktopLegacyLocalStorage.DesktopLegacyLocalStorage; - // Chromium writes localStorage to disk lazily; persist the merge before - // the marker stops later launches from retrying it. + // Flush Chromium's lazy writes before the marker prevents migration retries. if (event) Electron.webContents.fromId(event.sender.id)?.session.flushStorageData(); yield* legacy.complete; }), diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c23..c6c2cd8fbdf9a 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -262,11 +262,7 @@ const clearCache = DesktopIpc.makeIpcMethod({ }), }); -/** - * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an orphaned - * partition. - */ +/** Keep the default profile's bare environment id so existing logins retain their partition. */ export function resolvePartitionScope( environmentId: string, profileId: string | undefined, @@ -278,9 +274,7 @@ export function resolvePartitionScope( if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { return { scope: environmentId, persistent: true }; } - // JSON's tuple framing is injective for strings, including lone UTF-16 - // surrogates (which it escapes). URI encoding throws on those supported ids, - // while replacing them with U+FFFD would collapse distinct identities. + // JSON framing preserves lone UTF-16 surrogates; URI encoding throws and replacement would collide. return { scope: JSON.stringify([environmentId, profileId]), persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, @@ -288,11 +282,7 @@ export function resolvePartitionScope( }; } -/** - * Clearing without a profile keeps the historical "everything" behaviour for - * an explicit all-profiles action; naming a profile confines it to that - * profile's partition so one profile's sign-out cannot reach the others. - */ +/** Omitting a profile clears every partition; a named profile clears only its own partition. */ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartitions")(function* ( manager: PreviewManager.PreviewManager["Service"], environmentId: string, @@ -300,10 +290,7 @@ const resolveClearPartitions = Effect.fn("desktop.ipc.preview.resolveClearPartit ) { if (profileId === undefined) return undefined; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Loading the session is what puts the partition in the map the clear walks. - // Deriving the partition string alone leaves nothing to match, so clearing a - // profile with no tab open this run — after a restart, or when deleting a - // profile — would report success and delete nothing. + // getBrowserSession registers the partition; deriving its name alone leaves unopened profiles uncleared. yield* manager.getBrowserSession(scope, persistent, namespace); return [yield* manager.getBrowserPartition(scope, persistent, namespace)]; }); @@ -315,9 +302,7 @@ const getPreviewConfig = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId, profileId }) { const manager = yield* PreviewManager.PreviewManager; const { scope, persistent, namespace } = resolvePartitionScope(environmentId, profileId); - // Creating the session first is what installs the UA rewrite and permission - // handlers; a guest that attached to an untouched partition would run with - // Electron's default UA and Chromium's default permission behaviour. + // Session creation installs UA and permission handlers before a guest can attach. yield* manager.getBrowserSession(scope, persistent, namespace); return { partition: yield* manager.getBrowserPartition(scope, persistent, namespace), @@ -346,8 +331,6 @@ export const importBrowserCookies = DesktopIpc.makeIpcMethod({ ...importInput }) { const browserImport = yield* BrowserImport.BrowserImport; - // Derived in main from the same helper the webview config uses, so cookies - // land in exactly the partition the profile's tabs attach to. const { scope, persistent, namespace } = resolvePartitionScope( environmentId, importInput.targetProfileId, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index e9b66f6a7463d..bee960764eb03 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -139,9 +139,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ runningDistro, httpBaseUrl: httpBaseUrl.href, wsBaseUrl: toWebSocketBaseUrl(httpBaseUrl), - // A backend launched with the desktop secret accepts whichever token the secret derives - // for the current window, so hand out that one rather than the token frozen into its - // launch config. + // Use the current window's derived token; the launch config's token may be stale. ...(bootstrap.desktopBootstrapSecret ? { bootstrapToken: yield* configuration.currentBootstrapToken } : bootstrap.desktopBootstrapToken @@ -195,9 +193,7 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - // Fall back to the persisted wslDistro when the id is the - // "wsl:default" sentinel; the orchestrator uses the same fallback - // for the actual backend. + // Match the orchestrator's persisted distro fallback for wsl:default. const wslDistro = useWsl ? (wslDistroFromTarget ?? settings.wslDistro) : null; const defaultPath = useWsl ? Option.fromNullishOr( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 3c10f04512b4c..dac7550871460 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -22,8 +22,7 @@ const readWslState: Effect.Effect< const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); - // Only enumerate distros when WSL is actually available — listDistros on a - // non-WSL host would spawn wsl.exe and hit the timeout for nothing. + // Listing distros on non-WSL hosts spawns wsl.exe only to time out. const distros = available ? yield* wslEnvironment.listDistros : []; const preflightError = yield* wslBackend.lastPreflightError; return { @@ -67,8 +66,7 @@ export const setWslBackendEnabled = makeIpcMethod({ yield* lifecycle.relaunch(`wslBackendEnabled=${enabled}`); return state; } - // Reconcile is idempotent and never fails; no need for a swap-style - // rollback when the WSL side has trouble coming up. + // Reconcile never fails, so no rollback is needed. yield* wslBackend.reconcile; return yield* readWslState; }), @@ -84,8 +82,7 @@ export const setWslDistro = makeIpcMethod({ const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslDistro(distro); const settings = yield* appSettings.get; - // In active wsl-only mode the pool's primary IS the WSL backend, and its - // distro is captured when that backend starts, so relaunch to replace it. + // WSL-only primary backends capture their distro at startup; relaunch to change it. if (settings.wslBackendEnabled && settings.wslOnly && change.changed) { const state = yield* readWslState; yield* lifecycle.relaunch(`wslDistro=${distro ?? "default"}`); @@ -101,10 +98,7 @@ export const setWslOnly = makeIpcMethod({ payload: Schema.Boolean, result: DesktopWslStateSchema, handler: Effect.fn("desktop.ipc.wsl.setOnly")(function* (enabled) { - // wsl-only decides which backend the pool spins up as "primary", and that - // decision is captured once at layer init. A disabled WSL backend always - // leaves Windows primary active, so mode changes can be staged without a - // relaunch and applied by the subsequent enable call. + // The pool captures its primary at initialization; disabled WSL can stage mode changes until enable. const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const change = yield* appSettings.setWslOnly(enabled); diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index aaf0c11bcce92..ce7bc4004cf4c 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -26,14 +26,12 @@ describe("linuxSecretStorage", () => { it("forces gnome-libsecret for desktops Electron recognizes but leaves on basic text", () => { expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt" })).toBe("gnome-libsecret"); - // Chromium stops at the first recognized value, so a later name cannot rescue the session. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:GNOME" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:KDE" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "LXQt:plasma" })).toBe("gnome-libsecret"); }); it("does not treat lowercase desktop names as ones Electron recognizes", () => { - // Chromium matches these case-sensitively, so lowercase spellings reach basic text. expect(autoSwitch({ XDG_CURRENT_DESKTOP: "gnome" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "xfce" })).toBe("gnome-libsecret"); expect(autoSwitch({ XDG_CURRENT_DESKTOP: "kde", KDE_SESSION_VERSION: "6" })).toBe( diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index 81f891985b9d0..b6d41b43a6640 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -8,8 +8,7 @@ export type LinuxPasswordStoreSwitch = Exclude layerDesktopApplicationRuntime.pipe(Layer.provideMerge(Layer.succeedContext(clerkContext))), diff --git a/apps/desktop/src/permissions/MacPermissionHelper.ts b/apps/desktop/src/permissions/MacPermissionHelper.ts index 4a8e38961f20a..0b50e147785f7 100644 --- a/apps/desktop/src/permissions/MacPermissionHelper.ts +++ b/apps/desktop/src/permissions/MacPermissionHelper.ts @@ -88,8 +88,7 @@ export class MacPermissionHelper { const bundle = macAppBundlePath(Electron.app.getPath("exe")); if (!bundle) return; if (owner?.isDestroyed()) return; - // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts. - // Use the same PNG that packaging uses to generate the app's macOS icon. + // Finder can return generic icons for mounted artifacts; use the packaging PNG. const appIcon = iconPaths .map((iconPath) => Electron.nativeImage.createFromPath(iconPath)) .find((image) => !image.isEmpty()); diff --git a/apps/desktop/src/permissions/MacSettingsWindow.ts b/apps/desktop/src/permissions/MacSettingsWindow.ts index f529d82b59413..e5acc09abbf92 100644 --- a/apps/desktop/src/permissions/MacSettingsWindow.ts +++ b/apps/desktop/src/permissions/MacSettingsWindow.ts @@ -15,9 +15,8 @@ const SettingsWindow = Schema.NullOr( export type SettingsWindow = typeof SettingsWindow.Type; const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow)); -// Window bounds and owner PIDs are available before Screen Recording is granted. -// Use the bundle identifier rather than the localized app/window title. A single -// process avoids launching osascript repeatedly while the user moves Settings. +// Window bounds/PIDs need no Screen Recording grant. Match the bundle id to avoid localized titles. +// Keep one osascript process while Settings moves. const SETTINGS_WINDOW_SCRIPT = ` ObjC.import("CoreGraphics"); ObjC.import("AppKit"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b502d4be5b273..9b94aefe25231 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -46,8 +46,7 @@ try { const clientPlatform = process.platform; if (clientPlatform === "darwin") { - // Native window buttons do not scale with Chromium zoom. Keep their reserved - // space in native points, including when a zoomed page is reloaded. + // Native buttons ignore Chromium zoom; reserve native points even after reload. const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", diff --git a/apps/desktop/src/preview/AnnotationIds.test.ts b/apps/desktop/src/preview/AnnotationIds.test.ts index 1104c422f3eea..f1f4be518455c 100644 --- a/apps/desktop/src/preview/AnnotationIds.test.ts +++ b/apps/desktop/src/preview/AnnotationIds.test.ts @@ -4,8 +4,6 @@ import { createAnnotationIdSource } from "./AnnotationIds.ts"; describe("createAnnotationIdSource", () => { it("gives annotations picked on separate page loads distinct ids", () => { - // The picker preload runs afresh on every page load, while the composer - // keeps earlier annotations keyed by id. const firstLoad = createAnnotationIdSource(); firstLoad("element"); const firstAnnotation = firstLoad("annotation"); diff --git a/apps/desktop/src/preview/AnnotationIds.ts b/apps/desktop/src/preview/AnnotationIds.ts index 2262febdd1c15..082d6540a64d8 100644 --- a/apps/desktop/src/preview/AnnotationIds.ts +++ b/apps/desktop/src/preview/AnnotationIds.ts @@ -1,11 +1,4 @@ -/** - * Id source for picker annotations and their targets, one per page load. - * - * The composer keeps annotations keyed by id across preview reloads, and this - * preload restarts on every page load, so a bare counter reissues ids that a - * draft already holds. `getRandomValues` rather than `randomUUID`, because - * plain-http previews (LAN, tailnet) are not secure contexts. - */ +/** Per-load nonces avoid draft ID collisions; getRandomValues works on plain HTTP. */ export function createAnnotationIdSource(): (prefix: string) => string { const pageLoad = Array.from(crypto.getRandomValues(new Uint32Array(2)), (part) => part.toString(36), diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 4815da11cba1a..c691ac15174d6 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -46,8 +46,6 @@ const withImporter = Effect.fnUntraced(function* () { const root = helium.userDataDirectory(context); if (root === undefined) throw new Error("Helium has no macOS user-data directory"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); - // The cookie database is what marks a source as installed, so a fixture - // without one is reported as absent before any other check runs. yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( @@ -96,8 +94,6 @@ describe("BrowserImport.importCookies", () => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { importer, root } = yield* withImporter(); - // The lock Chromium leaves while it is running, dangling target and - // all. This must stop the import before it ever asks the keychain. yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); const error = yield* importer diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e7f97eca26c02..c567b27411c0c 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -41,8 +41,7 @@ export class BrowserImportFailedError extends Schema.TaggedError 0) { yield* Effect.tryPromise(() => session.cookies.flushStore()).pipe( Effect.tapError((error) => @@ -152,7 +147,7 @@ export const writeCookies = Effect.fn("BrowserImport.writeCookies")(function* ( return { imported, skipped, skippedDomains: [...skippedDomains].slice(0, 20) }; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserImportMake() { const browserSession = yield* BrowserSession.BrowserSession; const platform = yield* HostProcess.Platform; @@ -200,19 +195,14 @@ export const make = Effect.gen(function* BrowserImportMake() { } if (platform === "darwin" && definition.engine === "chromium") { - // macOS attributes the Keychain prompt and the resulting ACL grant to the - // executable that asks, so record which one that was — in a packaged build - // it is the signed app, in dev whatever binary hosts the main process. + // macOS attributes Keychain prompts and ACL grants to the executable, which differs in dev and packaged builds. yield* Effect.logInfo("Reading browser cookie key from the keychain", { sourceId: definition.id, executablePath, }); } - // The profile directory arrives over IPC, so it is only honoured when the - // source itself reported it. Forwarding it unchecked would let `..` - // segments walk out of the browser's user-data directory and read any - // cookie database reachable on disk. + // IPC profile paths must match the source's listing to prevent traversal outside browser user-data. const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); @@ -226,8 +216,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - // The profile was listed against a database moments ago; resolve it again - // rather than assume a path, since a Chromium jar may sit under `Network/`. + // Re-resolve listed paths because Chromium jars may be under Network/. const databasePath = yield* resolveCookieDatabase( definition, pathContext, @@ -296,8 +285,7 @@ export const make = Effect.gen(function* BrowserImportMake() { ), ); - // Written one at a time rather than in parallel: Chromium's cookie store - // serialises writes anyway, and a rejected cookie should only cost itself. + // Chromium serializes writes; a rejected cookie should not prevent the others. return yield* writeCookies(session, result); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index 91be0a43d1be8..e5aa05f268e43 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -38,9 +38,6 @@ const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { - // Chromium stores a host-only cookie without a leading dot. Passing any - // `domain` to Electron makes it a domain cookie and re-adds the dot, which - // would expose the cookie to every subdomain it was never scoped to. expect(cookieScope("example.test", "/", true)).toEqual({ url: "https://example.test/", domain: undefined, @@ -288,8 +285,7 @@ describe("readChromiumCookieDatabase", () => { const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); - // The key some Linux clients actually encrypted with (crbug.com/1195256): - // OSCrypt's derivation over an empty passphrase. + // Some Linux clients encrypted with an empty passphrase (crbug.com/1195256). const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); yield* Effect.gen(function* () { @@ -352,9 +348,6 @@ describe("readChromiumCookieDatabase", () => { ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); - // Chromium's OSCrypt returns unprefixed data as-is on both platforms - // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear - // text"), so neither counts it as undecryptable. const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index d5f7592b26068..63fef9fb3c808 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -73,12 +73,7 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect( Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), ); -/** - * Unspecified is imported as Electron's own `unspecified` rather than pinned to - * Lax, so the target browser applies its default just as the source did; - * anything unrecognised lands there too, since guessing "none" would widen a - * cookie's scope on import. - */ +/** Preserve unspecified SameSite; guessing no_restriction would widen cookie scope. */ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; if (value === 1) return "lax"; @@ -86,21 +81,14 @@ const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { return "unspecified"; }; -/** - * The microsecond value overflows JavaScript's safe integer range and - * `node:sqlite` refuses to narrow it, so the division happens in SQL and this - * only ever sees seconds. - */ +/** Convert microseconds in SQL: node:sqlite refuses integers beyond JavaScript's safe range. */ const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; const toUnixSeconds = (webkitSeconds: number): number | undefined => { if (webkitSeconds <= 0) return undefined; return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; }; -/** - * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding - * a cookie to its domain. - */ +// Chromium 127+ prefixes plaintext with the host-key SHA-256 to bind its domain. const stripDomainBinding = ( plaintext: Buffer, domain: string, @@ -161,17 +149,14 @@ export function decryptChromiumValue( const prefix = buffer.subarray(0, 3).toString("latin1"); const payload = buffer.subarray(3); - // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and - // intentionally have no key here, so they fall through as undecryptable. + // Windows v10 uses AES-256-GCM; app-bound v20 records have no key here. if (platform === "win32") { return prefix === "v10" && keys.gcmV10 ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) : null; } - // Chromium retries a failed record with a key derived from an empty - // passphrase, because some Linux clients wrote data that way - // (crbug.com/1195256). + // Retry the empty-passphrase key used by some Linux clients (crbug.com/1195256). if (prefix === "v10") { if (!keys.cbcV10) return null; return ( diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 0d91ebe5a3746..bc9edcfe52a80 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -14,7 +14,6 @@ import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; const KEY_SALT = "saltysalt"; const KEY_LENGTH = 16; -/** macOS stretches the keychain secret; Linux uses a single iteration. */ const MAC_KEY_ITERATIONS = 1003; const LINUX_KEY_ITERATIONS = 1; /** Chromium's documented fallback passphrase when no Linux keyring is present. */ @@ -42,11 +41,7 @@ export interface ChromiumKeyMaterial { readonly cbcV10?: Buffer; readonly cbcV11?: Buffer; readonly cbcV11Error?: ChromiumKeyError; - /** - * AES-128-CBC from an empty passphrase. Some Linux clients wrote records - * with it (crbug.com/1195256), so Chromium — and this import — retry with it - * after a record's own key fails. - */ + /** Linux clients can encrypt with an empty passphrase; retry it on failure (crbug.com/1195256). */ readonly cbcEmpty?: Buffer; readonly gcmV10?: Buffer; } @@ -54,21 +49,12 @@ export interface ChromiumKeyMaterial { const derive = (passphrase: string, iterations: number) => NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); -/** - * Via the CLI the prompt says "security" and "Always Allow" grants trust to a - * tool every process on the machine can invoke; in-process it names this app - * and the grant belongs to it. - * - * Deliberately untimed: macOS answers this with a modal, and a timeout racing - * the user means the prompt can be approved while nothing is left listening, - * which reads as "approving did nothing". - */ +// In-process Keychain access attributes grants to this app. Keep modal prompts untimed to preserve their listener. const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( service: string, account: string, ) { - // Only macOS cookie imports need this binding; loading it at startup can - // prevent the desktop from opening on platforms that never use it. + // Loading the macOS binding at startup can prevent other platforms from opening. const Keyring = yield* Effect.tryPromise({ try: () => import("@napi-rs/keyring"), catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), @@ -90,10 +76,7 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function return secret; }); -/** - * Its exit codes distinguish a missing key, denied access, and an unavailable - * keyring without parsing localized error messages. - */ +/** Exit codes distinguish missing keys, denied access, and unavailable keyrings without localized message parsing. */ export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( application: string, ) { @@ -267,13 +250,10 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") } if (request.platform === "linux") { - // The fallback passphrase always applies to `v10` records; a keyring - // secret, when one is reachable, additionally unlocks `v11`. + // v10 always uses the fallback passphrase; a keyring secret additionally unlocks v11. const keyringSecret = request.linuxSecretApplication ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( - // v10 remains importable when Secret Service is absent or does not - // contain a key. An explicit denial/lock/cancel remains a consent - // failure rather than being silently downgraded. + // Missing Secret Service or keys must not block v10; explicit denial, lock, or cancellation remains a consent failure. Effect.catchIf( (error) => error.reason !== "needsKeychainApproval", (error) => Effect.succeed(error), diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts index 2eb6cdbb5a17b..6c708e6023df2 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -9,11 +9,7 @@ export interface ImportedCookie { readonly url: string; readonly name: string; readonly value: string; - /** - * A host-only cookie leaves this undefined: Electron treats any `domain` it is given as marking a - * domain cookie and re-adds the dot, which would widen the cookie to every subdomain of the host - * it was scoped to, and rejects `__Host-` cookies, which require it to be absent. - */ + /** Leave host-only cookies undefined; Electron widens any supplied domain and rejects __Host- cookies with one. */ readonly domain: string | undefined; readonly path: string; readonly secure: boolean; @@ -47,10 +43,7 @@ export const cookieScope = ( export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); -/** - * Both engines keep the file open with WAL while the browser runs, so reading in place can observe - * a torn write. Copying also guarantees we never open the browser's own file for writing. - */ +/** Copy before reading to avoid live WAL writes and never open the source browser database for writing. */ export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( cookiePath: string, tempPrefix = "t3code-cookie-import-", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index d764b5bd6d007..be41318fa01e6 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -37,8 +37,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rawSameSite?: number; originAttributes?: string; }>, - // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in - // milliseconds, earlier ones in seconds. schemaVersion = 15, ) { const fileSystem = yield* FileSystem.FileSystem; @@ -46,7 +44,6 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( const file = `${directory}/cookies.sqlite`; const database = new NodeSqlite.DatabaseSync(file); database.exec(`pragma user_version = ${schemaVersion}`); - // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; database.exec( `create table moz_cookies ( @@ -87,8 +84,6 @@ describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => run( Effect.gen(function* () { - // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older - // profiles still hold seconds. Both must land as seconds for Electron. const row = { host: "example.test", name: "c", @@ -169,9 +164,6 @@ describe("readFirefoxCookies", () => { it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => run( Effect.gen(function* () { - // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite - // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in - // to cross-site use; importing it as "none" would widen its scope. const row = { host: "example.test", name: "c", @@ -198,8 +190,6 @@ describe("readFirefoxCookies", () => { it.effect("imports rows whose SameSite was never written", () => run( Effect.gen(function* () { - // Schema 9 added `sameSite` without a default, so rows from before the - // upgrade hold NULL. One such row must not fail the whole import. const row = { host: "example.test", name: "c", @@ -229,10 +219,6 @@ describe("readFirefoxCookies", () => { it.effect("applies the schema-15 rawSameSite rule to older databases", () => run( Effect.gen(function* () { - // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value - // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by - // default, None declared" into Unset; an unmigrated database has to be - // read the same way or an undeclared cookie becomes an explicit Lax. const row = { host: "example.test", name: "c", @@ -276,9 +262,6 @@ describe("readFirefoxCookies", () => { sameSite: 1, }, { - // Same host, name and path as above: Firefox keeps these apart by - // container, Electron cannot, so importing both would hand the - // profile whichever one happened to be written last. host: "mail.test", name: "session", value: "work-container", @@ -340,8 +323,6 @@ describe("readFirefoxCookies", () => { describe("parseFirefoxProfiles", () => { it.effect("reads named profiles and ignores Install sections", () => Effect.gen(function* () { - // `Install*` sections name a default profile but do not describe one, so - // counting them would invent a profile whose directory does not exist. const parsed = yield* parsePosixFirefoxProfiles( [ "[Install4F96D1932A9F858E]", diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 16ad488f5cc2f..a5fddb14d22df 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -17,23 +17,12 @@ export class FirefoxCookieReadError extends Schema.TaggedError= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Firefox isolates cookies per container and per private window via - // `originAttributes` (`^userContextId=2`, `^privateBrowsingId=1`); - // Electron has no equivalent, so importing them all would collapse - // several identities onto one host/name/path and hand the profile an - // arbitrary container's session. + // Electron cannot represent Firefox containers/private windows; importing them would collapse distinct sessions. const raw = hasRawSameSite ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 5420b9fc8bd50..7e92334c957de 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -158,8 +158,6 @@ describe("parseBinaryCookies", () => { }); it("rejects a page that runs past the end of the file", () => { - // `Buffer.subarray` clamps rather than throwing, so an overlong first page - // swallows the second one's bytes and advances the cursor past the end. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); @@ -215,9 +213,6 @@ describe("parseBinaryCookies", () => { describe("readSafariCookies", () => { it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { - // What Full Disk Access actually looks like: the file is there, the read - // is refused with EPERM. Effect tags that `Unknown`, not - // `PermissionDenied`, so the reader has to look at the errno. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts index 15f8de8d3a36e..118f9af1f776a 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -8,7 +8,6 @@ import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; /** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; -/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ const COOKIE_PAGE_HEADER_SIZE = 12; const COOKIE_RECORD_HEADER_SIZE = 56; @@ -46,10 +45,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray buffer.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } @@ -74,9 +70,7 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray page.length) { throw new SafariCookieReadError({ reason: "readFailed" }); } - // Every record accepted so far, so a later offset cannot point back into - // one of them: the page header, the offset table, and earlier records are - // all bytes that would otherwise parse as a fabricated cookie. + // Reject offsets into the header, table, or earlier records to avoid fabricated cookies. const accepted: Array = []; for (let index = 0; index < cookieCount; index += 1) { const cookieStart = page.readUInt32LE(8 + index * 4); @@ -120,9 +114,6 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; cookies.push({ - // Safari marks domain cookies with a leading dot like the other - // engines, so the shared scope rule applies: host-only cookies keep - // `domain` undefined, or Electron widens them to every subdomain. ...cookieScope(domain, path || "/", secure), name, value, @@ -130,17 +121,13 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { - // TCC denies with EPERM, which Effect tags `Unknown` rather than - // `PermissionDenied` — so the errno is what identifies it. EACCES (and the - // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or - // ACL failure that granting Full Disk Access cannot fix, so it stays a plain - // read failure rather than sending the user to a grant that won't help. + // TCC returns EPERM (Effect Unknown); EACCES is a POSIX denial that Full Disk Access cannot fix. const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; return code === "EPERM"; }; -/** - * `stat` succeeds on the jar inside Safari's container even without Full Disk - * Access — that is what lets the listing find it — so presence alone cannot - * tell granted from denied. - * */ +/** stat succeeds without Full Disk Access, so presence alone does not prove readable cookies. */ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts index 1bad2ec2b3572..8e1d2c3d62d4d 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.ts @@ -17,8 +17,7 @@ export const safariPermissionCheck = Effect.gen(function* () { if (!safari || context.platform !== "darwin") return false; const defaultJar = yield* resolveCookieDatabase(safari, context, "."); if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar); - // A Safari installation can have cookies only in a named profile. Rediscover - // those stores after a grant, since TCC may have hidden their metadata before. + // Rediscover named profiles after a grant; TCC may have hidden their metadata. const profiles = yield* listSourceProfiles(safari, context); for (const profile of profiles) { const jar = yield* resolveCookieDatabase(safari, context, profile.directory); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index cc92563c5847c..d0444d42e5021 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -119,9 +119,6 @@ describe("isSourceRunning", () => { const context = yield* withSourceHome(); assert.isFalse(yield* isSourceRunning(helium, context)); - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", `${userDataDirectory(context)}/SingletonLock`, @@ -209,9 +206,6 @@ describe("isSourceInstalled", () => { const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Installers for native messaging hosts create an empty user-data - // directory for every Chromium fork they know about, so treating the - // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, context)); @@ -307,8 +301,6 @@ describe("cookieDatabaseCandidatePaths", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Chromium 96+ keeps sessions in Network/; a root Cookies left behind - // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); @@ -565,14 +557,9 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // `.parentlock` is deliberately left on disk after a clean exit as a - // last-used marker, so an unlocked one is not evidence of a running - // browser — treating it as one blocked every import after first use. yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // The `lock` symlink is what Firefox removes on exit; a live pid in - // its target means the profile is held. yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); assert.isTrue(yield* isSourceRunning(firefox, context)); }), @@ -582,11 +569,7 @@ describe("isSourceRunning for Firefox", () => { it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); - // The resolver may hand Firefox any of the machine's addresses, not - // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address - // elsewhere — so every local address counts as ours. const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); - // Both the plain and the fcntl-marked (`+`) forms carry the pid. assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); @@ -603,8 +586,7 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - // Firefox's win32 root hangs off %APPDATA%; without it the root is - // undefined and the fixture would escape the sandbox into the repo. + // Without APPDATA, the Windows Firefox fixture would escape into the repo. Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, @@ -616,8 +598,6 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.makeDirectory(profile, { recursive: true }); yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3e1cae508a69e..0bf3df235f946 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -73,9 +73,7 @@ const chromiumSource = (input: { }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - // No Chromium fork is importable on Windows: since Chrome 127 their cookies - // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. + // Chrome 127+ App-Bound Encryption prevents other processes from reading Windows Chromium cookies. chromiumSource({ id: "chrome", name: "Chrome", @@ -190,8 +188,7 @@ const cookieDatabaseCandidatePaths = ( if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } - // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade - // leaves the legacy file behind, so prefer the current one and fall back. + // Prefer Chromium 96+'s Network/Cookies; upgrades leave the pre-96 Cookies file behind. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), @@ -209,11 +206,7 @@ export const resolveCookieDatabase = Effect.fnUntraced(function* ( return undefined; }); -/** - * Firefox records its profiles in `profiles.ini`. `Install*` sections point at - * a default profile but do not describe one, so only `[ProfileN]` blocks - * count. - */ +/** Install* sections select defaults, not profiles; only ProfileN blocks describe profiles. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, @@ -242,8 +235,7 @@ export function parseFirefoxProfiles( if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { - // Firefox supports profiles on arbitrary custom roots when - // IsRelative=0. Do not constrain them to the standard Firefox root. + // IsRelative=0 permits custom roots outside Firefox's standard directory. directory = path.normalize(candidate); } @@ -308,10 +300,7 @@ const isSafeProfileDirectory = (directory: string): boolean => const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); -/** - * Firefox containers use identities Electron cannot represent, so its count - * uses the same default-container predicate as the reader. - */ +// Count only default-container cookies because Electron cannot represent Firefox containers. const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -420,12 +409,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. When - // none of the declared profiles has one, fall through to the scan rather - // than returning empty: `profiles.ini` can list stale or never-launched - // profiles while the cookies live in one it does not mention, and an empty - // answer here hides the browser entirely. + // Declared profiles may be stale or never launched; scan for unlisted stores if none has a cookie database. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -460,11 +444,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), - // The keys are directory names from the browser's own metadata file, which - // anything running as the user can write. Anything but a single plain - // segment is dropped: `..` or a path separator would otherwise be handed - // to `cookieDatabasePath` and read a database outside the user-data - // directory. + // Browser metadata is user-writable; reject traversal segments before resolving cookie database paths. Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), Effect.map((entries) => entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), @@ -490,11 +470,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** - * Include Firefox's Snap home alongside its native home. Snap profiles use - * absolute directories so cookie reads and lock checks keep pointing at the - * installation they came from, even when both installs use the same name. - */ +/** Keep Snap profile paths absolute so reads and locks distinguish native and Snap installations. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -544,9 +520,7 @@ export const chromiumProcessIsAlive = ( signalProcess(pid, 0); return true; } catch (cause) { - // Only ESRCH positively proves the process is gone. Permission errors - // and unknown failures stay conservative so an active browser is never - // mistaken for a stale lock. + // Only ESRCH proves a stale lock; permission and unknown failures may still hide an active browser. return !( typeof cause === "object" && cause !== null && @@ -570,8 +544,7 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( if (!/^\d+$/.test(pidText)) return true; const pid = Number(pidText); if (!Number.isSafeInteger(pid) || pid <= 0) return true; - // A PID is meaningful only on this host. A foreign hostname can come from a - // shared home directory, and cannot safely be declared stale from here. + // Foreign-host PIDs may come from a shared home; never declare them stale here. if (host !== currentHost) return true; return yield* isProcessAlive(pid); }); @@ -580,15 +553,9 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; -/** - * Whether a Windows `parent.lock` is actually held by a running process. It - * is opened with no sharing, so it persists on disk after the process exits - * and `stat` always succeeds; only trying to open it for write reveals an - * active holder, which surfaces as `Busy`. - */ +/** Windows leaves parent.lock after exit; only opening it for write reveals a live holder as Busy. */ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { - // Permission failures are distinct: they do not prove a browser owns the - // lock, so they must not hide the source as running. + // Permission failures do not prove a running browser owns the lock. const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( Effect.as(false), @@ -600,11 +567,7 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { type WindowsLockProbe = (path: string) => Effect.Effect; -/** - * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The - * live cookie database is opened without sharing instead, so probing each - * profile's current jar is the reliable running signal there. - */ +/** Windows Chromium locks cookie databases without sharing instead of creating POSIX SingletonLock symlinks. */ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -628,9 +591,7 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( ) { const separator = target.lastIndexOf(":"); if (separator < 0) return true; - // The owner half is whatever Firefox's resolver returned for the machine's - // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN - // address — so a pid is only meaningful when that address is one of ours. + // Firefox resolves the hostname to loopback or LAN addresses; only local owners' PIDs are meaningful. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -641,18 +602,10 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); -/** - * `/usr/bin/python3` is named absolutely first so a Dock-launched app with - * launchd's bare `PATH` still finds it without depending on the login-shell - * PATH merge; Linux distributions carry python3 on the default path. - */ +/** Dock launches have a bare PATH; try the absolute interpreter before relying on shell discovery. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; -/** - * Anything else means the script never ran — most importantly Apple's - * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits - * non-zero after printing an install prompt, without ever reaching our code. - */ +/** Apple's python3 shim can fail with an install prompt before the probe script runs. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + @@ -664,12 +617,9 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Anything else — no interpreter on any candidate path, or one that refused to - * run the script (Apple's shim without the developer tools) — is the probe - * being unavailable, not evidence about the lock. That case falls back to "not - * held" rather than "held": reporting every profile as locked forever would - * block Firefox import outright on such machines, and the SQLite snapshot - * already copes with a live database's WAL, as it does for every other engine. + * A missing or refusing interpreter (Apple's shim without developer tools) means + * the probe is unavailable, so report "not held": "held" would block Firefox import + * forever, and the SQLite snapshot already copes with a live WAL. */ const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -699,12 +649,7 @@ const posixLockIsHeld = Effect.fnUntraced(function* ( return false; }); -/** - * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* after - * exit, as a last-used marker; treating them as proof of a running browser - * blocks every import after Firefox has been used once. - */ +/** Firefox leaves regular lock files after exit; test the lock, not file existence. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, @@ -739,8 +684,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Safari keeps no lock and writes its jar atomically, so a running instance - // is not a hazard there. + // Safari writes its jar atomically and needs no lock, even while running. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -766,18 +710,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") return found.some(Boolean); }); -/** - * Keyed off the cookie database rather than the user-data directory, because - * that directory is not evidence the browser exists: installers for native - * messaging hosts create an empty one for every Chromium fork they know about, - * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. - * - * Existence is checked without opening the file, which matters for Safari: TCC - * permits `stat` on the jar inside its container but refuses a read, so this - * still sees it and the user gets the Full Disk Access prompt rather than - * having Safari disappear. - */ +/** Require a cookie database; installers create empty directories. Safari stat needs no Full Disk Access. */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 52d20c6e057bc..cb8e3f77a2ff6 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -86,8 +86,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these - // two supported ids would hash to one partition and share every cookie. const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); @@ -117,13 +115,8 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so the - // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { - // Electron's real UA shape: app token, then Chrome, then Electron, then - // Safari — the token order and casing matter to any strip regex. const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; fromPartition.mockReset(); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 3847ec3323316..0f900d521b739 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -12,37 +12,21 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -/** - * Incognito partitions deliberately omit the `persist:` prefix, which is what - * makes Chromium keep them in memory and discard them with the process. They - * still carry the product prefix so `isPartition` can admit them — the - * `will-attach-webview` gate rejects anything it does not recognise. - */ +/** Ephemeral partitions omit persist: but retain a product prefix so the webview gate recognizes them. */ const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; -// `clipboard-sanitized-write` is the Electron permission behind -// `navigator.clipboard.writeText()` — note it is NOT `clipboard-write`, which -// is not a valid Electron permission name. Async clipboard writes are gated by -// the permission *check* handler (not only the request handler), so both -// handlers must allow it; otherwise built-in "Copy" buttons — e.g. the Next.js -// / Vercel error overlay — fail with `Failed to execute 'writeText' on -// 'Clipboard': Write permission denied`. +// Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", "geolocation", - // `DesktopWindow` disables HTML fullscreen window resizing for the app window - // and every preview guest, so a page that goes fullscreen fills its own - // webview instead of taking over the whole T3 window. + // DesktopWindow confines HTML fullscreen to the guest's webview. "fullscreen", - // Deliberately NOT local-fonts: preview sessions run untrusted web content, - // and silently granting it would hand every page the user's installed-font - // fingerprint (and font file bytes via FontData.blob()). - // Deliberately NOT notifications: a page could post OS notifications branded - // as T3 Code. T3's own come from the app window on the default session. + // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes. + // Deny notifications: pages could impersonate T3 Code; app notifications use the default session. ]); const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet = new Set([ @@ -166,12 +150,7 @@ const selectSessions = ( ([partition]) => partitions === undefined || partitions.includes(partition), ); -/** - * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` - * and `"p\ufffd"` would hash to the same partition and share cookies. Those - * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` - * first — and a literal backslash is doubled so the escape cannot be forged. - */ +/** Escape lone surrogates before UTF-8 hashing, which otherwise merges them with U+FFFD; escape backslashes too. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( scope @@ -182,10 +161,7 @@ const encodeScopeForDigest = (scope: string): Uint8Array => ), ); -/** - * A `` guest has no window of its own, so look it up through the page - * that embeds it. - */ +// Webview guests have no window; use their embedding page. const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { if (guest === null || guest.isDestroyed()) return undefined; const host = guest.hostWebContents ?? guest; @@ -193,18 +169,14 @@ const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined return BrowserWindow.fromWebContents(host) ?? undefined; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const electronDialog = yield* ElectronDialog.ElectronDialog; const runFork = Effect.runForkWith(yield* Effect.context()); // One prompt at a time, so a page cannot stack dialogs by looping a deep link. let externalPromptOpen = false; - /** - * Electron grants an external-protocol navigation through the `openExternal` - * permission and then launches the OS handler itself. Ask first: previews run - * untrusted pages, and a silent grant would let any of them launch apps. - */ + /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */ const confirmOpenExternal = ( requester: WebContents | null, rawUrl: string | undefined, @@ -276,11 +248,7 @@ export const make = Effect.gen(function* BrowserSessionMake() { return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); - // The guest keeps Electron's native User-Agent. Rewriting it in any - // form — even variants that keep the Electron token — makes Cloudflare - // Turnstile fail its integrity check with error 600010 and recreate - // the challenge every few seconds, so logins behind it never complete - // (#5002). + // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010. browserSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { if (permission === "openExternal") { diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 2d17a28744545..ec314d1f9d478 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -104,7 +104,6 @@ describe("CDP relay", () => { expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); await settle(); - // Electron passes an empty session id for the page's own events. relay.event("Page.loadEventFired", { timestamp: 1 }, ""); relay.event("Runtime.consoleAPICalled", {}, "iframe-session"); expect(written.slice(-2)).toEqual([ diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..82264cc4b870b 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,7 +1,4 @@ -/** - * Electron allows one debugger client per webContents, so the relay shares the - * attach the preview manager already holds. - */ +// Electron allows one debugger per webContents; share the manager's attachment. export interface CdpRelayTarget { readonly send: ( @@ -14,10 +11,7 @@ export interface CdpRelayTarget { readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. - */ + /** The desktop and its server share a disk; download paths need no translation. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -56,11 +50,7 @@ export function createCdpRelayConnection( ): CdpRelayConnection { const send = (message: Record) => write(JSON.stringify(message)); let attached = false; - /** - * Sessions this relay invented. Extra page sessions (Playwright's - * `newCDPSession`) all reach the one real debugger session, so each gets the - * page's events; other ids are the page's own child sessions, such as iframes. - */ + /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { @@ -143,10 +133,7 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; - // The page itself is already running, but its child frames are not: - // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses - // every new cross-site iframe and worker until this reaches it. Swallowing - // it left sign-in checks and captchas, which run in such frames, frozen. + // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } @@ -167,8 +154,7 @@ export function createCdpRelayConnection( ? browserCommand(command) : pageCommand(command); const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; - // Replies leave as commands finish, as Chromium's do, so a slow command - // such as a screenshot never holds up the ones behind it. + // Reply as commands finish so slow commands cannot block later replies. void result.then( (value) => { const announce = diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2d..849ae658a8219 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. -/** - * The preview manager owns the tab's single debugger session and hands it here; - * the relay shares it. - */ +/** The manager owns the single debugger attachment; the relay shares it. */ import { DesktopBrowserCommand, DesktopBrowserEvent, @@ -156,10 +153,7 @@ export const make = Effect.gen(function* () { return relay; }; - /** - * Without a path Electron would open its Save dialog over the app for a file - * the agent asked for. CDP names the download just before this runs. - */ + // CDP supplies the path before this runs; otherwise Electron opens a Save dialog. const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index a2b36d182a8f3..4716645c9defa 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -669,8 +669,7 @@ async function rasterizeFavicon( ); if (signal.aborted) onAbort(); }); - // The logical timeout does not cancel Electron's renderer work. Keep the - // gate closed until that physical execution actually settles. + // Logical timeouts leave renderer work running; keep the gate closed until it settles. const launchAllowed = execution.then( () => undefined, () => undefined, diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 6cb297ff81e90..b531182c92959 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; -/** A guest whose page reports `focusedDocument` for the focus probe. */ const makeGuest = ({ focused, focusedDocument = true, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 35006dfcec08b..42201b48ed61c 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,26 +1,12 @@ -/** - * Keyboard and text input for a preview tab's page that never lands outside it. - * - * Chromium sends a page's CDP key and text input to whichever frame holds focus - * in the window. A `` guest shares that focus with the app around it, - * so while a person works in the app, `Input.insertText` on the guest's own - * debugger types into the app's focused field. Such input goes through CDP only - * while the guest holds focus. Otherwise it goes straight to the guest's own - * widget, which nothing redirects. - */ +// CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. -/** CDP input Chromium delivers to the window's focused frame rather than the page's. */ const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition", ]); -/** - * Page script: the document holding the page's focus, or null when a cross-site - * frame holds it. Electron's direct input reaches only the main frame's process, - * and `insertText` crashes the page when focus is in another one. - */ +/** Direct input reaches only the main-frame process; insertText crashes when a cross-site frame has focus. */ const FOCUSED_DOCUMENT = `(() => { let doc = document; for (;;) { @@ -31,11 +17,7 @@ const FOCUSED_DOCUMENT = `(() => { } })()`; -/** - * macOS editing commands a key press asks for (Cmd+A is `selectAll`), which - * Chromium runs only for CDP keys. Clipboard commands stay out: they would read - * or replace the person's clipboard. - */ +/** Electron runs editing commands only for CDP keys; exclude clipboard commands to preserve the user's clipboard. */ const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); const CROSS_SITE_FOCUS_MESSAGE = @@ -74,18 +56,13 @@ const MODIFIER_BITS = [ [8, "shift"], ] as const; -/** Electron's accelerator name for a DOM key value, or null when it has none. */ const keyCodeFor = (key: string) => { if ([...key].length === 1) return key; if (ARROW_KEYS[key]) return ARROW_KEYS[key]; return NAMED_KEYS.has(key) || /^F(?:[1-9]|1\d|2[0-4])$/u.test(key) ? key : null; }; -/** - * The Electron key events for one CDP `Input.dispatchKeyEvent`. Electron - * derives key, code, and a char's text from `keyCode`, so typed text travels - * as `char` events of its own. - */ +/** Electron derives text from keyCode, so typed text needs separate char events. */ export const toKeyboardInputEvents = ( params: Record, ): Array => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1ba..85fa6115477a0 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -80,8 +80,6 @@ describe("previewWindowOpenAction", () => { }); it("opens a real window for scripted popups so the opener survives", () => { - // OAuth SDKs read a null `window.open()` as a blocked popup, and they need - // the opener alive to receive the credential back. expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); expect( PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), @@ -89,7 +87,6 @@ describe("previewWindowOpenAction", () => { }); it("keeps a form POST with a body on the in-place path", () => { - // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( PreviewManager.previewWindowOpenAction({ @@ -100,9 +97,6 @@ describe("previewWindowOpenAction", () => { }); it("does not hand a window to schemes that cannot be hardened", () => { - // A popup skips the `will-attach-webview` hardening, so it only gets a window - // when its preferences can be overridden. Chromium copies the guest's - // preferences for `about:blank` and forbids overriding them. for (const url of [ "javascript:alert(1)", "file:///etc/passwd", @@ -114,8 +108,6 @@ describe("previewWindowOpenAction", () => { }); it("denies a blank window instead of blanking the opener", () => { - // MSAL opens `about:blank` first and falls back to a redirect on `null`; - // loading the blank page into the tab would destroy that fallback. for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); @@ -249,7 +241,6 @@ const withManager = ( interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; - /** Only fixtures that deliver picture-in-picture frames need pixels to fingerprint. */ readonly toBitmap?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } @@ -608,7 +599,6 @@ describe("PreviewManager", () => { const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); - // Electron names one arbitrary guest here when several share a focused window. getFocusedWebContents.mockReturnValue(newer.webContents as never); fromId.mockReturnValue(older.webContents); yield* manager.createTab("tab_older"); @@ -634,7 +624,6 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); - // The person types in the older tab, so keys reaching the newer one are an agent's. Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -770,9 +759,6 @@ describe("PreviewManager", () => { effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { - // Real Electron throws on any `wc.debugger` access once the - // WebContents is destroyed, so cleanup must go through the debugger - // reference captured at attach time (electron/electron#53376). let destroyed = false; let attached = false; const debuggerOff = vi.fn(); @@ -1061,7 +1047,6 @@ describe("PreviewManager", () => { id, isDestroyed: () => destroyed, isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. if (destroyed) throw new TypeError("Object has been destroyed"); return false; }, @@ -1173,11 +1158,7 @@ describe("PreviewManager", () => { audible = next; listeners.get("audio-state-changed")?.({ audible: next } as never); }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ + /** Simulate playback starting during attach before the tab owns the guest and can receive its events. */ startPlayingAfterFirstRead: () => { audibleAfterFirstRead = true; }, @@ -1237,9 +1218,6 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. expect(states.at(-1)?.audible).toBe(true); }), ), @@ -1369,7 +1347,6 @@ describe("PreviewManager", () => { fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. const first = yield* manager.captureScreenshot("tab_1"); const second = yield* manager.captureScreenshot("tab_1"); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae40..b9e0192ca8a7d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,3 @@ -/** - * Desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ import * as NodeZlib from "node:zlib"; import { DesktopPreviewRecordingInputSchema, @@ -123,10 +116,7 @@ const ZOOM_EPSILON = 0.001; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; -/** - * Cold guests can reject capturePage with UnknownVizError or never settle it. - * Bound each attempt so snapshots release control even when Chromium stalls. - */ +// Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; @@ -325,22 +315,12 @@ const cropAnnotationScreenshot = ( return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; -/** - * Electron converts `toBitmap()` output to sRGB by default. Captures on P3 Macs are - * P3, so that conversion costs more per tick than the JPEG encode the fingerprint - * replaces. An invalid target color space has no Skia color space, so `readPixels` - * copies the pixels without a color transform. The fingerprint only compares frames - * with each other, so color accuracy is irrelevant. - */ +// An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; -/** - * Identity of a captured frame: cheap enough to compute every tick, unlike a JPEG - * encode. Null when the pixels could not be read (Electron reports a failed - * `readPixels` as an empty buffer), so the caller treats the frame as changed. - */ +/** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { @@ -421,10 +401,7 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; - // Electron's Debugger is GC-managed but registered with Chromium as a raw - // DevToolsAgentHostClient pointer; collecting it while attached crashes the - // browser process (electron/electron#53376). Detach must also go through - // this reference: `wc.debugger` throws once the WebContents is destroyed. + // Retain the attached Debugger to prevent GC crashes (electron/electron#53376); wc.debugger throws after destruction. readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; @@ -443,13 +420,7 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; -/** - * `about:blank` stays out: Chromium skips browser-side navigation for it, so the child copies - * the guest's `contextIsolation: false` preferences and Electron gives no way to override them. - * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits - * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal` and not in a - * window spawned by a third-party page in the preview. - */ +// about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); const isPopupUrl = (rawUrl: string): boolean => { @@ -460,12 +431,7 @@ const isPopupUrl = (rawUrl: string): boolean => { } }; -/** - * A popup is not a webview attach, so the `will-attach-webview` hardening in `DesktopWindow` - * never sees it, and an unoverridden child would inherit the guest's relaxed posture: the - * picker preload needs `contextIsolation: false` to share `globalThis` with the previewed page, - * and no OAuth provider should get that. - */ +/** Popups bypass will-attach-webview hardening and would inherit the picker's contextIsolation:false. */ const POPUP_WINDOW_OPTIONS = { webPreferences: { contextIsolation: true, @@ -474,18 +440,13 @@ const POPUP_WINDOW_OPTIONS = { }, } satisfies Electron.BrowserWindowConstructorOptions; -/** - * `"popup"` opens a real window, which scripted popups need: denying them makes `window.open()` - * return `null` (OAuth SDKs report that as a blocked popup), and navigating the preview tab - * instead destroys the opener the popup has to `postMessage` its result back to. Schemes a - * popup cannot be hardened for keep loading in place, and so does a form POST with a body, - * which a new tab could only reopen as a GET. - */ +/** OAuth needs a surviving popup opener; unhardenable schemes and form POSTs must stay in place. */ export const previewWindowOpenAction = (details: { readonly url: string; readonly disposition: Electron.HandlerDetails["disposition"]; readonly postBody?: Electron.PostBody; }): "popup" | "new-tab" | "navigate" | "deny" => { + // MSAL redirects on a denied blank popup; navigating the tab would destroy that fallback. if (details.url === "" || details.url === "about:blank") return "deny"; if (!isPopupUrl(details.url)) return "navigate"; if (details.disposition === "new-window") return "popup"; @@ -590,9 +551,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const pictureInPictureAspectRatiosRef = yield* Ref.make>(new Map()); const pictureInPictureMutationSemaphore = yield* Semaphore.make(1); const closingTabIdsRef = yield* Ref.make>(new Set()); - // Tab recording uses `setDisplayMediaRequestHandler` because Electron's legacy - // `getMediaSourceId` + `chromeMediaSource: "tab"` capture path was removed upstream - // (electron#44618) and now always rejects with NotAllowedError. + // Legacy tab capture is unsupported (electron/electron#44618); recording uses setDisplayMediaRequestHandler. let pendingRecording: PendingRecording | null = null; const displayMediaHandlerSessions = new WeakSet(); let frameCaptureWindowOpen = true; @@ -897,10 +856,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ] as const; }); - // emitIfCurrent, not emit: an event-driven writer such as syncTabAudible - // can commit between the modify above and here, and republishing this - // snapshot would roll the UI back to a value that writer will not send - // again because it suppresses unchanged audibility. + // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { if (patch.controller !== undefined && next.value.webContentsId != null) { const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); @@ -917,11 +873,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } }); - /** - * Anything that applies zoom after an await goes through here: a snapshot taken before the - * await can be older than a zoom action that landed in between, and re-applying it would roll - * that action back. - */ + /** Read zoom after awaits so concurrent zoom actions cannot be rolled back by an older snapshot. */ const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (!tab || tab.webContentsId == null) return; @@ -932,11 +884,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ).pipe(Effect.ignore); }); - /** - * Mute counterpart to {@link assertTabZoom}: pushes the tab's committed mute - * onto whichever guest it currently owns, reading both at call time so an - * older snapshot can never roll back a mute action that landed after it. - */ + /** Read committed mute and current guest at call time so stale snapshots cannot roll back newer actions. */ const assertTabAudioMuted = Effect.fn("PreviewManager.assertTabAudioMuted")(function* ( tabId: string, ) { @@ -1098,8 +1046,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const scope = yield* Scope.fork(parentScope, "sequential"); const wcDebugger = wc.debugger; const consoleReleases = yield* Queue.sliding(1); - // Console message eviction does not release the debugger's strong object handles. We - // keep text only, so release the whole group, including its object-id bookkeeping. + // Console eviction leaves strong debugger handles; release the group because we keep only text. yield* Effect.forkIn( Effect.forever( Queue.take(consoleReleases).pipe( @@ -1198,9 +1145,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("detach", onDetach); wcDebugger.attach("1.3"); }); - // Without this, dark-scheme pages with no background of their own (text/plain, e.g. - // .md files) render white text on white. Sent first because a document that paints - // before it arrives keeps the transparent base until its next load. + // Set an opaque base before first paint or background-less dark pages retain white-on-white text. yield* attemptPromise( { operation: "initializeDebugger.defaultBackground", webContentsId: wc.id }, () => @@ -1250,9 +1195,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const url = wc.getURL(); const title = wc.getTitle(); if (url === "" || url === "about:blank") return { kind: "Idle" }; - // Main frame only. `isLoading()` covers the whole frame tree, so a - // cross-origin iframe that loads after the page can leave it true with no - // later event to clear it, and the tab's loading bar never finishes. + // isLoading() includes late iframes whose completion may never clear the tab's loading bar. if (wc.isLoadingMainFrame()) return { kind: "Loading", url, title }; return { kind: "Success", url, title }; }; @@ -1291,9 +1234,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!current || current.webContentsId !== wc.id || webContents.fromId(wc.id) !== wc) { return [Option.none(), tabs] as const; } - // Electron emits did-stop-loading after did-fail-load. At that point the - // failed guest is no longer "loading", but it has not successfully - // navigated anywhere. + // did-stop-loading follows did-fail-load without proving successful navigation. const navStatus = preserveLoadFailure && current.navStatus.kind === "LoadFailed" && @@ -1311,9 +1252,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - // zoomFactor is deliberately not read back from the guest: Chromium - // reports the level it inherited from the app window, so mirroring it - // would turn an app zoom into the preview's own zoom. + // Guest zoom inherits app zoom; mirroring it would overwrite the tab's own setting. updatedAt, }; return [ @@ -1517,8 +1456,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }).pipe(Effect.ignore), ); }; - // Not getFocusedWebContents(): every guest of a focused window reports focus. - // An agent's keys reach a guest without a focused frame through GuestInput. + // Every guest of a focused window reports focus; GuestInput handles guests without a focused frame. const holdsKeyboardFocus = (contents: Electron.WebContents) => contents.isFocused() && contents.focusedFrame !== null; const syncMenuShortcuts = (contents: Electron.WebContents, input: Electron.Input): void => { @@ -1527,9 +1465,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !isPreviewEditingShortcut(input, hostPlatform) || !holdsKeyboardFocus(contents), ); }; - // A popup opens with Electron's default handler, so the page inside it could - // otherwise spawn native windows without limit. Nothing in an OAuth flow - // opens a second popup, so the chain stops at the first one. + // Popups inherit Electron's unrestricted window handler; block nested popups to bound the chain. const windowCreated = (window: Electron.BrowserWindow): void => { window.webContents.setIgnoreMenuShortcuts(true); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); @@ -1872,15 +1808,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Always assert the tab's own zoom rather than reading the guest's: a guest - // attaching while the app UI is zoomed starts at the embedder's inherited - // zoom level, which is not the preview's zoom. Done before the guest is - // published so it never paints a frame at the inherited zoom. + // Restore the tab's zoom before publishing the guest so it never paints at inherited app zoom. yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => wc.setZoomFactor(currentTab.zoomFactor), ); - // A replacement guest attaches unmuted, so reassert the tab's mute before it is published - // rather than letting it emit audio the user already silenced. + // Replacement guests start unmuted; restore mute before publishing them. yield* attempt({ operation: "registerWebview.restoreAudioMuted", tabId, webContentsId }, () => wc.setAudioMuted(currentTab.audioMuted), ); @@ -1934,16 +1866,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; - // A zoom or mute action that landed while this attach was in flight - // addressed the guest this one replaced, so settle the new guest on the - // committed values. + // Concurrent actions targeted the previous guest; reconcile this one with committed zoom and mute. yield* assertTabZoom(tabId); yield* assertTabAudioMuted(tabId).pipe(Effect.ignore); runFork(restoreControlSession(tabId, wc)); yield* emitIfCurrent(tabId, registered); - // Transitions that fired before the tab owned this guest were dropped by - // syncTabAudible's ownership check, so re-read and reconcile through the - // same path the event uses. + // Ownership checks dropped pre-attach audibility events; reconcile through the event path. yield* syncTabAudible(tabId, wc, yield* readAudible); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => { wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme); @@ -1975,9 +1903,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document paints. A tab opened - // straight to a URL often paints before the renderer gets to registerWebview, which would - // leave that page on the transparent base. + // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { @@ -2016,10 +1942,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Mute is user intent, and the old document keeps playing until loadURL - // actually replaces it, so clearing audibility here would drop the - // speaker with no transition left to restore it. Chromium reports the - // change when it happens. + // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", @@ -2177,8 +2100,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("destroyed", onDestroyed); wc.off("did-start-navigation", onNavigated); }).pipe(Effect.ignore); - // Only drop the slot while it is still ours. A newer session may - // already have swapped itself in before cancelling this one. + // A newer session may already own the slot when this one is cancelled. yield* Ref.update(pickSessionsRef, (sessions) => sessions.get(tabId) === session ? replaceMap(sessions, (copy) => { @@ -2270,9 +2192,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function : { annotation: { ...payload, screenshot }, submission }, }), Effect.flatMap((result) => { - // A capture that outlives its session must not touch the - // overlay: the preload tears down on the captured signal, and - // by now it may be running a newer pick. + // Late captures must not signal an overlay that may now belong to a newer pick. if (!claimSettle()) return Effect.void; return attempt( { operation: "pickElement.captureComplete", tabId, webContentsId: wc.id }, @@ -2291,10 +2211,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { - // Two picks on one tab can overlap. Swap this session in and cancel - // the previous holder in one step, so no third pick can slip into an - // empty slot in between and the session we push out still resumes - // its renderer. + // Atomically swap/cancel overlapping picks so another pick cannot claim a transient empty slot. const replaced = yield* Ref.modify(pickSessionsRef, (sessions) => [ sessions.get(tabId) ?? null, replaceMap(sessions, (copy) => { @@ -2302,9 +2219,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ]); if (replaced) yield* replaced.cancel; - // A newer pick may have cancelled this session while the previous - // one was torn down. Cleanup already ran, so attaching listeners now - // would leak them and start an overlay nobody is waiting on. + // A newer pick may cancel during teardown; do not leak listeners or start an abandoned overlay. if (settled) return; yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); @@ -2352,9 +2267,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); - // Emulated media lives on the CDP debugger session, not the WebContents, so - // it is lost whenever the session detaches (webview swap, DevTools - // open/close) and must be re-applied after every (re)attach. + // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, @@ -2440,8 +2353,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (tab.colorScheme !== colorScheme) { yield* update(tabId, { colorScheme }); } - // Re-read after the update: registerWebview may have swapped the guest - // in the meantime and the override must land on the current one. + // registerWebview may swap guests during the update; apply to the current one. const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; if (webContentsId == null) return; const wc = webContents.fromId(webContentsId); @@ -2457,10 +2369,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (!tab) { return yield* new PreviewTabNotFoundError({ tabId }); } - // Commit and apply under the tab's lifecycle lock, then assert the - // committed value rather than this call's argument. Two overlapping toggles - // would otherwise be free to commit in one order and reach Chromium in the - // other, leaving the icon disagreeing with the guest. + // Serialize commit/apply, then read committed mute so concurrent toggles cannot reach Chromium out of order. yield* withTabLifecycleLock( tabId, Effect.gen(function* () { @@ -2469,8 +2378,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (committed) { yield* update(tabId, { audioMuted }); } - // Roll the commit back if Chromium refused: reporting success here - // would leave the tab drawn as muted while it keeps playing. + // Roll back rejected mute commits so the UI reflects actual playback. yield* assertTabAudioMuted(tabId).pipe( Effect.tapError(() => committed ? update(tabId, { audioMuted: previous }) : Effect.void, @@ -2582,9 +2490,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); if (frameSession?.scope !== captureSession.scope) return; - // Fingerprint the raw pixels before encoding so an unchanged page skips the - // synchronous JPEG encode, the base64 copy and the IPC send. An unreadable - // frame has no fingerprint and is always delivered. + // Fingerprint before encoding so unchanged frames skip JPEG, base64, and IPC; unreadable frames must still be sent. const fingerprint = fingerprintCapturedFrame(image, size); const pictureInPicture = frameSession.consumers.has("picture-in-picture") && @@ -2939,8 +2845,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (hostPlatform === "darwin") { pictureInPictureWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, - // Electron otherwise temporarily transforms the entire app into - // a UIElement process, which removes the owning app from the Dock. + // Electron's temporary UIElement transformation would remove the app from the Dock. skipTransformProcessType: true, }); } @@ -3075,10 +2980,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (pendingRecording?.tabId === tabId) pendingRecording = null; }; - /** - * A display-media request carries no tab identity, so the slot is exclusive: a second tab - * arming before the first request lands would redirect the first renderer's stream. - */ + /** Display-media requests lack tab identity; only one tab may arm a recording to prevent stream redirection. */ const armPendingRecording = Effect.fn("PreviewManager.armPendingRecording")(function* ( tabId: string, wc: Electron.WebContents, @@ -3105,8 +3007,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function armedAtMillis: now, }; pendingRecording = armed; - // The handler callback is sync and cannot read a clock, so expiry is driven from here. - // Identity compare: a re-arm replaces the object, and this fiber must not clobber it. + // The synchronous callback cannot read a clock. Compare identity so expiry cannot clobber a re-arm. yield* Effect.forkIn( Effect.sleep(RECORDING_ARM_GRACE_MS).pipe( Effect.andThen( @@ -3119,8 +3020,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that - // `startRecording` armed, and denies anything else so pages cannot capture on their own. + // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); @@ -3679,7 +3579,7 @@ export class PreviewManager extends Context.Service< } >()("@t3tools/desktop/preview/Manager/PreviewManager") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; diff --git a/apps/desktop/src/preview/PasskeyBackendMac.ts b/apps/desktop/src/preview/PasskeyBackendMac.ts index ccdffa2a70c08..91cefaddc42f1 100644 --- a/apps/desktop/src/preview/PasskeyBackendMac.ts +++ b/apps/desktop/src/preview/PasskeyBackendMac.ts @@ -6,8 +6,7 @@ import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; const ES256 = -7; -// WebAuthn rejects RP IDs that are public suffixes, private registries -// included, so one github.io site cannot mint passkeys for every other one. +// WebAuthn rejects public suffix RP IDs, including private registries such as github.io. const isPublicSuffix = (domain: string) => domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; @@ -32,13 +31,10 @@ const toBase64Url = (value: BufferSource) => : new Uint8Array(value), ).toString("base64url"); -/** - * Needs Apple's managed browser entitlement in the signed build. - */ +/** Requires Apple's managed browser entitlement in the signed build. */ export const macPasskeyBackend: PasskeyBackend = { create: async (publicKey, context): Promise => { - // The native layer only converts P-256 keys and never settles for any - // other algorithm, so ES256 is the only one it may negotiate. + // The native layer only converts P-256 keys, so negotiate only ES256. const params: unknown = publicKey.pubKeyCredParams; if (params !== undefined && !Array.isArray(params)) return { success: false, error: "TypeError" }; @@ -56,8 +52,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer reports parsed authenticator data as JSON and claims - // every credential is a synced platform passkey; neither is reliable. + // Native parsed authenticator JSON and synced-platform-passkey claims are unreliable. const authData = authenticatorDataFromAttestation( Buffer.from(result.data.attestationObject, "base64url"), ); @@ -79,8 +74,7 @@ export const macPasskeyBackend: PasskeyBackend = { options, ); if (!result.success) return { success: false, error: result.error }; - // The native layer applies the site's allow list to platform passkeys only, - // so a security key can answer with a credential the site did not ask for. + // Native allow lists cover only platform passkeys; security-key responses need checking here. const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) .map((credential) => toBase64Url(credential.id)); diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts index 3b72ab5890757..998a9298baf3c 100644 --- a/apps/desktop/src/preview/PasskeyBridge.test.ts +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -27,10 +27,6 @@ const illegalInvocation = (): never => { throw new TypeError("Illegal invocation"); }; -/** - * Installs the bridge over fresh stand-ins for the page's DOM classes. Like - * Chromium's, their getters only work on objects the browser created itself. - */ const install = (respond: (channel: string) => unknown) => { const nativeCreate = vi.fn(async () => "native-create"); const nativeGet = vi.fn(async () => "native-get"); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts index fbb9fefa4aa5f..bc77b38c1cf0a 100644 --- a/apps/desktop/src/preview/PasskeyBridge.ts +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -44,10 +44,7 @@ const ceremonyError = (name: string) => DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", ); -/** - * Chromium ignores members it does not know, so functions and other uncloneable - * values drop out here instead of failing the whole ceremony. - */ +// Chromium drops unknown members; discard uncloneable values instead of failing the ceremony. const toCloneable = (value: unknown): unknown => { if (value instanceof ArrayBuffer) return value.slice(0); if (ArrayBuffer.isView(value)) { @@ -64,9 +61,7 @@ const toCloneable = (value: unknown): unknown => { return value; }; -// The objects below borrow the native prototypes so `instanceof` checks pass. -// Their own properties shadow the native getters, which only work on objects -// Chromium created itself. +// Shadow native getters: they reject objects Chromium did not create, even with the correct prototype. const withValues = (target: T, values: Record) => { for (const [key, value] of Object.entries(values)) { Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); @@ -202,10 +197,7 @@ const credentialFromGetResult = (data: Extract signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); -/** - * Conditional (autofill and automatic upgrade) requests stay native: the system - * sheet is modal and must not open on its own. - */ +/** Keep conditional requests native so autofill and upgrades cannot open a modal sheet unprompted. */ export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { return; diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts index f848aff47b8f9..219c67fac5061 100644 --- a/apps/desktop/src/preview/Passkeys.test.ts +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -120,7 +120,6 @@ describe("PreviewPasskeys", () => { assert.isFalse(options.isPublicSuffix("example.com")); assert.isFalse(options.isPublicSuffix("localhost")); - // WebAuthn defaults the RP ID to the caller's own host. yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); @@ -218,7 +217,6 @@ describe("PreviewPasskeys", () => { allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], }; - // macOS applies the allow list to passkeys but not to security keys. webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); @@ -244,7 +242,6 @@ describe("PreviewPasskeys", () => { const lan = makeGuest("http://192.168.1.5:3000"); passkeys.attachGuest(lan.guest); assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); - // macOS refuses `*.localhost`, so the page hears no rather than a native error. const subdomain = makeGuest("http://app.localhost:3000"); passkeys.attachGuest(subdomain.guest); assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts index 5456aaab6a8a2..34db1404aacb8 100644 --- a/apps/desktop/src/preview/Passkeys.ts +++ b/apps/desktop/src/preview/Passkeys.ts @@ -84,8 +84,7 @@ const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; -// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony -// unsettled, and the page must still get an answer. +// Native ceremonies can remain unsettled; match its deadline clamp with a margin. const ceremonyDeadline = (timeout: unknown) => (typeof timeout === "number" && timeout > 0 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) @@ -99,9 +98,7 @@ const withDeadline = (ceremony: Promise, milliseconds: nu return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); }; -// Secure contexts only: https, or http on this machine. Chromium also trusts -// `*.localhost`, but the macOS layer refuses it, so it is turned away here -// instead of failing deeper with a less useful error. +// macOS refuses *.localhost even though Chromium trusts it. const isTrustworthyOrigin = (origin: string) => { if (!URL.canParse(origin)) return false; const { protocol, hostname } = new URL(origin); @@ -112,9 +109,7 @@ const isTrustworthyOrigin = (origin: string) => { ); }; -/** - * Windows needs none: Chromium already hands WebAuthn to Windows Hello there. - */ +// Chromium already delegates to Windows Hello. const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => platform === "darwin" ? macPasskeyBackend : undefined; @@ -168,9 +163,7 @@ const make = Effect.gen(function* () { // Only the guest's main frame runs the bridge preload. const frame = event.senderFrame; if (!frame || frame !== guest.mainFrame) return notAllowed; - // The page shares a JS world with the preload, so nothing it sends can - // be trusted for the origin. Read the committed origin before any await: - // the frame object outlives a navigation. + // The page shares the preload's JS world; read the frame's committed origin before any await/navigation. const origin = frame.origin; if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { return notAllowed; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 4059468da22e9..4cb5987cdeb85 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -197,11 +197,7 @@ const navigationDirectionForButton = (button: number): "back" | "forward" | null return null; }; -// Chromium routes thumb-button history navigation to the *focused* WebContents, -// so hovering this guest without focusing it sends the host app's router back -// instead of the preview. Suppress Chromium's default here and drive this tab's -// history explicitly so the buttons always navigate the browser the pointer is -// over — never the host app. +// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. const suppressNavigationButton = (event: MouseEvent): void => { if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; event.preventDefault(); @@ -391,11 +387,7 @@ function toStackFrame(frame: { }; } -/** - * `getElementContext` walks the inspected page's React internals, and some pages leave it - * pending forever. Without a bound, the whole submit chain stalls and the overlay sits on - * "Capturing…". - */ +/** React-internals lookups can remain pending forever; bound them so submit cannot stall the overlay. */ function withCaptureTimeout(promise: Promise, millis: number): Promise { let timer: ReturnType | undefined; return Promise.race([ @@ -408,11 +400,7 @@ function withCaptureTimeout(promise: Promise, millis: number): Promise { const base = { pageUrl: location.href, @@ -1399,8 +1387,7 @@ function startAnnotation(sendEnabled: boolean): void { pendingCapture = true; submit.disabled = true; submit.textContent = "Capturing…"; - // The element context lookup can take up to its timeout, and the user can - // keep editing meanwhile; the annotation must describe what they submitted. + // Snapshot the submitted comment before context lookup lets the user edit it. const submittedComment = comment.value.trim(); const submittedRegions = [...regions]; const submittedStrokes = [...strokes]; @@ -1421,8 +1408,7 @@ function startAnnotation(sendEnabled: boolean): void { }), ) .then((elements) => { - // The overlay may have been cancelled or replaced while the capture - // ran. A late submit must not deliver into the next pick's listener. + // A late submit must not reach a replacement pick's listener. if (finished) return; const annotation: PreviewAnnotationPayload = { id: nextId("annotation"), @@ -1461,8 +1447,7 @@ function startAnnotation(sendEnabled: boolean): void { root.addEventListener("keydown", (event) => { const submission = event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null; - // Keep this in the bubble phase so editor inputs receive the event before - // it is isolated from listeners installed by the inspected page. + // Bubble phase lets editor inputs handle events before hiding them from the page. event.stopImmediatePropagation(); if (!submission) return; event.preventDefault(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 4524b0d017b61..5b9fb3c13bc37 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -11,11 +11,7 @@ import { type RecordingKeyPress, } from "./RecordingInput.ts"; -/** - * Chromium's capture cursor uses native window bounds, which do not follow a - * webview's CSS placement or scale. Draw it in the guest's coordinate space - * while recording, and make the native cursor transparent to avoid two cursors. - */ +/** Draw in guest coordinates; Chromium's native cursor ignores webview CSS placement/scale. Hide it to avoid duplicates. */ export function installRecordingCursor( document: Document, window: Window, diff --git a/apps/desktop/src/preview/WebviewPreferences.test.ts b/apps/desktop/src/preview/WebviewPreferences.test.ts index 182b6a505d166..572a290e2b669 100644 --- a/apps/desktop/src/preview/WebviewPreferences.test.ts +++ b/apps/desktop/src/preview/WebviewPreferences.test.ts @@ -6,7 +6,6 @@ function parseWebPreferences(input: string): Record { const out: Record = {}; for (const pair of input.split(",")) { if (pair !== pair.trim()) { - // Electron's parser doesn't trim; surface the bug as undefined-key. out[pair] = pair.split("=")[1]; continue; } @@ -21,9 +20,6 @@ describe("PREVIEW_WEBVIEW_PREFERENCES", () => { const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES); it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => { - // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so - // `contextIsolation="no"` would silently leave isolation ENABLED. Lock - // the values to `"true"` / `"false"` so the parser does the right thing. for (const value of Object.values(parsed)) { expect(value).toMatch(/^(true|false)$/); } diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 4236b78293ab1..4dc9d9aa6b732 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,9 +1,3 @@ -/** - * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical - * when paired with `contextIsolation=false` — without sandbox, the preload - * has full Node access (`require`, `fs`, `child_process`, ...) and that - * `require` would land on the page's shared `globalThis`, giving any - * third-party page in the preview full Node + IPC access to the host. - */ +/** sandbox:true is required with contextIsolation:false or the shared page global would expose Node and IPC. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295fa..433e8127a5098 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -37,8 +37,7 @@ export interface DesktopSettings { readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // Changing this requires a desktop restart because the pool's primary spec is - // chosen once at layer init. + // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; } @@ -466,7 +465,6 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }); /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 248fb9b6f8482..cccc0585024af 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -221,7 +221,7 @@ function decodeSecretBytes( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 3fdec7932366b..c80fe9b87cd6a 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -360,11 +360,6 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { - // Concurrent, not sequential: these two probes are independent (only their - // results are combined below) and each spawns its own PowerShell. Run in - // series they sit at offset 0 of desktop.startup, before anything else, and - // launch traces measured them at 2718ms then 2066ms — the entire 4.8s - // startup span, of which desktop.bootstrap is ~30ms. const [noProfile, profile] = yield* Effect.all( [ readWindowsEnvironment(["PATH"], { loadProfile: false }), @@ -432,9 +427,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. + // Forward mise's PATH delta too, or mise x can rebuild PATH into a recursive same-named wrapper. "__MISE_DIFF", ] as const; for (const name of shellPreferredEnvNames) { @@ -452,9 +445,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", - // CA trust set in the shell profile. Without it, a Node or OpenSSL build - // that has no bundled roots (a shared-OpenSSL node on PATH) cannot verify - // TLS, so provider updaters and agents fail to reach their registries. + // Shell CA settings are required by Node/OpenSSL builds without bundled roots for registry TLS. "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", @@ -465,8 +456,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // Locale variables form one precedence group: LC_ALL can override an inherited - // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + // Hydrate locale variables together; inherited LC_ALL can override LANG or LC_CTYPE. if ( config.platform === "darwin" && LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) @@ -478,11 +468,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } - // GUI launches inherit no locale from launchd, so spawned agents land in the C - // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported - // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale - // available on those releases. Leaving LANG unset keeps C-stable collation and - // formatting, so output parsing is unaffected. + // launchd lacks locale; UTF-8 LC_CTYPE fixes pbcopy's MacRoman decoding without changing C formatting or collation. if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { config.env.LC_CTYPE = FALLBACK_LC_CTYPE; } @@ -522,7 +508,7 @@ const installShellEnvironment = ( return Effect.succeed(false); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/snapShot/ActiveWindow.ts b/apps/desktop/src/snapShot/ActiveWindow.ts index f62669a39bf36..20f56cc19442c 100644 --- a/apps/desktop/src/snapShot/ActiveWindow.ts +++ b/apps/desktop/src/snapShot/ActiveWindow.ts @@ -6,10 +6,7 @@ import * as Schema from "effect/Schema"; import { loadWindowsForegroundApi } from "../electron/WindowsForeground.ts"; -/** - * `id` is the CGWindowNumber on macOS and the HWND on Windows, which is what - * the capture backends key on. - */ +/** id is CGWindowNumber on macOS or HWND on Windows, matching the capture backend. */ export type ActiveWindow = { readonly platform: "macos" | "windows"; readonly id: number; diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 39a7dddf5de72..dc3aa093f9cf6 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -798,7 +798,6 @@ it.effect.each(["darwin", "win32"] as const)( for (const finish of heldFlights) finish(); yield* Fiber.join(landing); - // Landing releases the guard even before the first capture is acknowledged. yield* Effect.promise(fixture.trigger); assert.equal(fixture.state.snapshots, 2); assert.lengthOf(fixture.readyIds, 2); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 164c97b4f22fc..e2f1c6e82a366 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -211,7 +211,6 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError { send(event, { ScreenshotCaptured: { path: "/another-app.png" } }); send(event, { ScreenshotCaptured: { path: screenshot.path } }); } - // The event may beat the command reply. send(socket, { Ok: "Handled" }); } else send(socket, { Ok: "Handled" }); }; diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index 374745c96e60c..f0ac7b9c1bba2 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,4 @@ -// Runs in a forked Node-mode child so the native screenshot call cannot crash -// or stall the main process. +// Isolate native screenshot crashes and stalls in a Node-mode child. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 3361aafc3c78a..9021c8904e3fe 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -172,8 +172,7 @@ async function readCapturedWindowAccessibility( .catch(() => { progress.flatComplete = true; }); - // A decorated screenshot contains more than the accessibility client area. Keep its - // frame origin/scale so element coordinates include the actual decoration offset. + // Preserve the screenshot frame origin and scale so accessibility coordinates include decorations. const sourceBounds = matchMode === "wayland" && active.clientBounds ? active.bounds diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts index dd1474b885052..542e3f13c08dc 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts @@ -49,8 +49,7 @@ export function startSnapShotAccessibilityProcess(workerPath: string): Accessibi let worker: NodeChildProcess.ChildProcess; try { worker = NodeChildProcess.fork(workerPath, ["read"], { - // Electron defaults to its Helper executable on macOS, which does not - // share the main app's Accessibility grant checked during setup. + // Electron's default macOS Helper executable lacks the main app's Accessibility grant. execPath: process.execPath, env: { ...process.env, diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index a8ca237009bbe..4b6ce04a1bf27 100644 --- a/apps/desktop/src/snapShot/SnapShotTransition.ts +++ b/apps/desktop/src/snapShot/SnapShotTransition.ts @@ -310,8 +310,7 @@ export class SnapShotTransition { } } if (this.waitForCompositorFrame) { - // Cover the source with a composited snapshot before the caller reveals T3. - // Decoding and renderer animation frames alone can leave a transparent gap. + // Renderer frames alone can leave a transparent gap; composite the snapshot before revealing T3. await Promise.all( overlays.map(async (overlay) => { if (this.active !== active || overlay.window.isDestroyed()) return; @@ -408,8 +407,7 @@ export class SnapShotTransition { ); if (this.active !== active || overlay.window.isDestroyed()) return; if (this.waitForCompositorFrame) { - // Read back one pixel after preparation to flush queued compositor work - // before the animation clock starts on any of the display surfaces. + // Read back one pixel to flush compositor work before starting any display's animation clock. await overlay.window.webContents.capturePage({ x: 0, y: 0, width: 1, height: 1 }); } return overlay; diff --git a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts index eb02eb7a03d13..ca4165626b260 100644 --- a/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts +++ b/apps/desktop/src/snapShot/WindowsCaptureFeedback.ts @@ -4,8 +4,7 @@ const WINDOW_ANIMATIONS_DISABLED = "wm-window-animations-disabled"; /** Show capture feedback without Aura's extra 200 ms fade/scale on transparent windows. */ export function showWindowsCaptureOverlay(window: Electron.BaseWindow): void { - // Chromium reads this switch synchronously inside Show(). Restore it before returning - // so other windows and CSS animations keep their normal behavior. + // Show() reads the switch synchronously; restore it before other windows or animations can be affected. const alreadyDisabled = Electron.app.commandLine.hasSwitch(WINDOW_ANIMATIONS_DISABLED); if (!alreadyDisabled) Electron.app.commandLine.appendSwitch(WINDOW_ANIMATIONS_DISABLED); try { diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index c35fee18db45e..59fe40b7c5212 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -4,8 +4,7 @@ import * as NodeURL from "node:url"; import type { SnapShotKeyChord } from "@t3tools/contracts"; import { nativeImage } from "electron"; -// Linux helpers that need no D-Bus. Keep this module free of dbus-next so the main -// process can answer "which desktop is this" and read a PNG without loading it. +// Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; diff --git a/apps/desktop/src/snapShot/snapShot.ts b/apps/desktop/src/snapShot/snapShot.ts index 958e017299e0e..28bd301c557e6 100644 --- a/apps/desktop/src/snapShot/snapShot.ts +++ b/apps/desktop/src/snapShot/snapShot.ts @@ -520,8 +520,7 @@ export function findAccessibleWindow< const browserMatch = findBrowserTitleMatch(windows, titles, captured, matchMode, matchesBounds); if (browserMatch) return browserMatch; } - // GTK4/libadwaita often exposes the frame as an unnamed group. A PID-scoped - // lookup can accept the one window whose bounds match; size-only guesses cannot. + // GTK4 frames may be unnamed; accept only a PID-scoped unique bounds match, never size alone. if (!options.allowUntitledUniqueBounds) return undefined; const boundsMatches = windows.filter( (window) => normalizeTitle(window.name ?? "") === "" && matchesBounds(window), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 61b104b4ac62b..eac148ae4d0f3 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -125,7 +125,7 @@ const makePasswordPrompt = ( prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 296b81339e80e..7442282aa15ad 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -218,7 +218,7 @@ const failPending = ( error: DesktopSshPasswordPromptRequestError, ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( options: DesktopSshPasswordPromptsOptions = {}, ) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 5e4b1de81b833..8800c4f428331 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -141,7 +141,7 @@ function sampleInterval( return LIVE_SAMPLE_INTERVAL; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const electronApp = yield* ElectronApp.ElectronApp; const powerMonitor = yield* ElectronPowerMonitor.ElectronPowerMonitor; diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 49acc3f86a483..253a015d8ec9e 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -22,9 +22,7 @@ import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; import { makeHarness } from "./updatesTestHarness.ts"; -// The remote flow hops between the test runtime's fibers and the updater's -// runPromise-driven event handlers, so settling needs real microtask turns, -// not just fiber yields. +// Updater runPromise handlers need real microtasks between test-runtime fiber yields. const settle = Effect.gen(function* () { for (let i = 0; i < 20; i += 1) { yield* Effect.yieldNow; @@ -326,8 +324,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("retries a download refused while the check still holds the reservation", () => { - // electron-updater emits update-available from inside checkForUpdates, - // before the check action releases its reservation. const releaseCheck = Deferred.makeUnsafe(); const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); @@ -359,9 +355,6 @@ describe("DesktopRemoteUpdates", () => { }); it.effect("waits for the download reservation before reporting prepared", () => { - // update-downloaded fires from inside downloadUpdate. If the flow - // reported "installing" right then, install would be refused for the - // held reservation after the irrevocable terminal already went out. const releaseDownload = Deferred.makeUnsafe(); const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts index cb1b86c07754b..cb606d8fbd253 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -175,11 +175,7 @@ export const listen: Effect.Effect< const { latest, changes } = yield* updates.subscribe; const disabledReason = Option.getOrNull(yield* updates.disabledReason); let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; - // A state event can land while the action that produced it still holds - // the reservation (e.g. "available" before the check releases), so a - // forked action can be refused with no later state event to retry on. - // Rejected actions re-enqueue their state here after a short pause so - // the step runs again once the reservation is free. + // State events can precede reservation release; re-enqueue refused actions so they retry after release. const retries = yield* Queue.unbounded(); const retryLater = (state: DesktopUpdateState) => Effect.sleep(ACTION_RETRY_DELAY).pipe( @@ -416,11 +412,7 @@ export const listen: Effect.Effect< result.state.downloadedVersion === claim.prepared.downloadedVersion && (yield* updates.isInstallActive) ) { - // Another install (local, or an earlier remote request) already owns - // the shutdown and will relaunch the app on the same downloaded - // version. This commit joins it: no failure marker, and the client - // proves the handoff the same way, by transport loss then the - // target version on reconnect. + // Join the existing install; reconnect and target-version checks prove its handoff. yield* logInfo("remote update commit joining an in-progress install", { requestId: commit.requestId, }); @@ -433,9 +425,7 @@ export const listen: Effect.Effect< } return; } - // A successful install tears down this backend. Do not send a success - // marker from the old process: transport loss followed by the target - // version is the only proof that the handoff succeeded. + // Installation kills this backend; only transport loss followed by the target version proves handoff. }).pipe( Effect.ensuring(clearActiveRequest(commit.requestId)), Effect.catchCause((cause) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 4fefdaa545826..5265d65523ba2 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -64,9 +64,7 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, - // Left unvalidated on purpose: a malformed release-notes payload must never - // fail the decode and block the update state transition. The shape is - // validated defensively in normalizeDesktopUpdateReleaseNotes. + // Malformed notes must not block update-state decoding; normalizeDesktopUpdateReleaseNotes validates separately. releaseNotes: Schema.optional(Schema.Unknown), }); @@ -335,7 +333,7 @@ function isArm64HostRunningIntelBuild(runtimeInfo: DesktopRuntimeInfo): boolean return runtimeInfo.hostArch === "arm64" && runtimeInfo.appArch === "x64"; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* DesktopConfig.DesktopConfig; const pool = yield* DesktopBackendPool.DesktopBackendPool; @@ -364,8 +362,7 @@ export const make = Effect.gen(function* () { ); const stateChanges = yield* PubSub.sliding(16); - // Makes ref writes + publishes atomic against subscribe, so a snapshot - // never overlaps with the first change a subscriber receives. + // Serialize writes and publishes against subscription so snapshots cannot overlap the first change. const stateMutex = yield* Semaphore.make(1); const emitState = Ref.get(updateStateRef).pipe( @@ -399,8 +396,7 @@ export const make = Effect.gen(function* () { ), ); - // The .deb carries electron-builder's resources/package-type marker. - // electron-updater reads the same file and installs updates with dpkg. + // electron-updater uses this marker to install .deb updates with dpkg. const isDebPackage = environment.platform === "linux" && environment.isPackaged ? yield* fileSystem @@ -597,8 +593,7 @@ export const make = Effect.gen(function* () { ), ); - // A failed or interrupted install brings no updated backend, so a later - // quit must release the tunnel. + // Failed or interrupted installs leave no replacement backend; later quits must release the tunnel. const removeUpdateRestartMarker = fileSystem .remove(updateRestartMarkerPath, { force: true }) .pipe(Effect.ignore); @@ -742,8 +737,7 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // SIGTERM + grace. The budget covers pausing running threads - // before the process exits, so they can be resumed after relaunch. + // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 008a83eab03db..028e42fa6e3b7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -81,8 +81,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("drops releases from other trains before grouping on nightly", () => { - // electron-updater's full changelog is "every version above the running - // one", and preview sorts above nightly, so the preview cuts come first. const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index ef6d217299d09..69d710ff7b269 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -30,8 +30,6 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { }; function decodeCodePoint(codePoint: number, entity: string): string { - // String.fromCodePoint throws RangeError outside the valid Unicode range, and - // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } @@ -124,12 +122,7 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } -/** - * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever train - * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` - * of the same base version and would lead the list. - */ +/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index f7c65b35820cc..1d9588315559c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -11,12 +11,7 @@ export type RemoteDesktopUpdateStep = readonly reason?: string; }; -/** - * The caps are what stop a check -> up-to-date -> check loop and endless - * download retries; they are counts rather than booleans because a state event - * raced by the local 4-minute poller can re-show an already-handled status - * once. - */ +/** Bound retries with counts: background poller races can repeat an already-handled status once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; readonly downloads: number; @@ -46,9 +41,7 @@ export function nextRemoteDesktopUpdateStep( reason: disabledReason ?? "Automatic updates are disabled on this machine.", }; } - // An install of this version already failed to apply here. Installing it - // again from afar would stop the app's threads for nothing, so only the user - // on this machine retries it. + // Reinstalling a failed version disrupts threads without updating; retries require the local user. if ( state.notAppliedVersion !== undefined && (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion @@ -59,11 +52,7 @@ export function nextRemoteDesktopUpdateStep( reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`, }; } - // Mirror installDownloadedUpdate's own admission rule exactly, so the run - // never reports an install that the updater then refuses. A download - // survives an unrelated background updater error (errorContext null) and - // a previous failed install (errorContext "install"); it does not survive - // a check or download error, which fall through to the error branch. + // Match install admission, including retained downloads after unrelated background or earlier install errors. if (isInstallableDesktopUpdateState(state)) { return { action: "install" }; } @@ -80,9 +69,7 @@ export function nextRemoteDesktopUpdateStep( } return { action: "download" }; } - // "up-to-date" and "error" are retained from earlier/background checks, - // so before this run has issued its own check they are stale, not - // terminal: the whole point of a remote request is to look again. + // Background up-to-date/error states are stale until this request has issued its own check. if (state.status === "up-to-date") { if (attempts.checks === 0) { return { action: "check" }; @@ -109,11 +96,7 @@ export function nextRemoteDesktopUpdateStep( return { action: "check" }; } -/** - * Updater messages are plain strings and may be blank; a blank reason would - * fail encoding and silently drop the terminal report, leaving the server to - * wait for its timeout. - */ +/** Blank reasons fail encoding and drop the terminal report, forcing the server to time out. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed ? trimmed : undefined; diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 1b1a1ec1efa99..d79a374a668a5 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,10 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// They share nightly's branding but are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel -// a preview install reports is cosmetic: it never checks for updates and no -// updater feed ever lists a preview release. +// Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index c392246ce045b..c50bf7fd6b3c9 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,14 +106,13 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { return; } - // The result dialogs need no main window. Opening one before the backend - // answers strands the renderer on a failed auth bootstrap. + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -143,11 +142,8 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - // macOS needs the native editing action for this chord; Windows and Linux - // already paste through Chromium. Avoid a renderer round trip for shortcuts: - // a delayed paste can duplicate the terminal's clipboard-read fallback. - // Keep mouse clicks on the renderer path so the composer arms its inline - // paste bypass before the paste, even without a keydown. + // macOS shortcuts use native editing; a renderer round trip can duplicate the terminal fallback. + // Mouse clicks use the renderer so the composer arms its inline paste bypass. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -247,11 +243,7 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - /* - Not the reload, DevTools or zoom roles: those act on the focused - webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. - */ + /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { label: "Force Reload", @@ -283,8 +275,7 @@ export const make = Effect.gen(function* () { ] : []), { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, - // The numeric keypad sends its own key codes, so the number-row - // accelerators above never match it. + // Keypad codes do not match number-row accelerators. { label: "Actual Size", accelerator: "CmdOrCtrl+num0", diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 40ab8c80a93ef..f05efcbd45b0a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -589,8 +589,6 @@ describe("DesktopWindow", () => { }), ); - // Ctrl+wheel bypasses the menu accelerators, so it has to take the same - // zoom step, preview restore included, from the window's zoom-changed event. it.effect("zooms the app from Ctrl+wheel like the menu does", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 297c67ddace08..6270f80a4a7e6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -37,8 +37,7 @@ import { makeQuitShortcutHandler } from "./QuitHold.ts"; import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. +// Match --workspace-topbar-height in apps/web/src/index.css; native macOS buttons stay 14 points at any zoom. const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; const MACOS_WINDOW_BUTTON_RADIUS = 7; @@ -109,9 +108,7 @@ export class DesktopWindow extends Context.Service< readonly dispatchSnapShotEvent: ( event: DesktopSnapShotEvent, ) => Effect.Effect; - // The Electron `zoomIn`/`zoomOut` menu roles act on whichever webContents - // has keyboard focus, so with an embedded preview WebContentsView (or - // DevTools) focused they zoom the guest page instead of the app UI. + // Electron zoom roles target focused guests; zoomMain must always target the app. readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect; readonly runMainContentsCommand: (command: MainWindowContentsCommand) => Effect.Effect; readonly syncAppearance: Effect.Effect; @@ -222,8 +219,7 @@ function concealPendingQuitWindow( if (window.isFullScreen()) { window.setFullScreen(false); } - // Electron implements window opacity on macOS and Windows. Linux keeps the - // release-gated quit behavior but cannot make the pending window disappear. + // Linux lacks Electron window opacity; release-gated quits remain visible there. window.setOpacity(0); } @@ -286,7 +282,7 @@ function bindFirstRevealTrigger( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopState = yield* DesktopState.DesktopState; @@ -315,11 +311,7 @@ export const make = Effect.gen(function* () { } }); - // currentMainOrFirst / focusedMainOrFirst fall back to "any first window", - // which during WSL-only boot is the connecting splash. The splash is never - // registered via setMain, so it must be treated as "no real main window" -- - // otherwise ensureMain/activate/dispatchMenuAction latch onto it and never - // open (or retry) the real main. + // Electron's first-window fallback can select the unregistered connecting splash; exclude it so main creation retries. const withoutSplash = (window: Option.Option) => Ref.get(splashWindowRef).pipe( Effect.map((splash) => @@ -341,9 +333,7 @@ export const make = Effect.gen(function* () { direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); if (environment.platform === "darwin") syncMacosWindowButtons(window); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. + // App zoom propagates to guests; restore each preview's own zoom. yield* previewManager.reapplyZoom(); }); @@ -394,18 +384,13 @@ export const make = Effect.gen(function* () { ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), webPreferences: { preload: environment.preloadPath, - // Chromium throttles hidden renderers: timers coalesce and rAF stops, - // which stalls first paint. Boot unthrottled; the first-reveal trigger - // re-enables throttling so a hidden or minimized window goes back to - // being cheap after it has been shown once. + // Hidden-renderer throttling stalls first paint; re-enable it after the first reveal. backgroundThrottling: false, contextIsolation: true, nodeIntegration: false, sandbox: true, webviewTag: true, - // A preview guest's fullscreen request is mirrored onto this embedder, - // which would otherwise put the whole window into OS fullscreen. With - // both sides opted out the page fills its webview and the window stays. + // Guest fullscreen propagates to the embedder; both must opt out to keep it inside the webview. disableHtmlFullscreenWindowResize: true, }, }); @@ -541,8 +526,7 @@ export const make = Effect.gen(function* () { contents.on("context-menu", (event, params) => { event.preventDefault(); if (contents.isDestroyed() || ownerWindow.isDestroyed()) return; - // Native editing roles act on the focused contents, which may still be - // the host renderer when the user right-clicks inside a browser guest. + // Right-clicking a guest may leave native editing roles targeting the host renderer. contents.focus(); const menuTemplate: Electron.MenuItemConstructorOptions[] = []; @@ -635,9 +619,7 @@ export const make = Effect.gen(function* () { } }); - // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the - // close-terminal shortcut can outlive the terminal that handled its first - // press, so reject repeats before they reach the native window accelerator. + // Terminal-close repeats can outlive the terminal; reject them before the native close-window accelerator. const quitShortcutHandler = makeQuitShortcutHandler({ platform: environment.platform, getMode: () => @@ -655,8 +637,7 @@ export const make = Effect.gen(function* () { window.webContents.send(QUIT_SHORTCUT_CHANNEL, hint); } }, - // Keep the transparent window focused until the physical shortcut is - // released so its remaining repeats cannot reach the next app. + // Hold focus until shortcut release so remaining repeats cannot reach the next app. concealWindow: () => concealPendingQuitWindow(window), quit: () => { void runPromise(electronApp.quit); @@ -673,9 +654,7 @@ export const make = Effect.gen(function* () { window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); }); - // Ctrl+wheel never reaches the menu accelerators: Chromium reports it here - // and leaves applying the zoom to the app. A preview guest under the - // pointer emits this on its own webContents and keeps its own zoom. + // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { if (window.isDestroyed()) return; runFork(zoomWindow(window, direction)); @@ -793,8 +772,6 @@ export const make = Effect.gen(function* () { details.reason === "oom" || details.reason === "killed" || details.reason === "abnormal-exit"; - // Recovery attempts are bounded so a renderer that dies immediately on - // boot cannot reload-loop forever. runFork( Effect.gen(function* () { @@ -959,8 +936,7 @@ export const make = Effect.gen(function* () { const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); - // The renderer must learn about the event even when another process refuses to - // yield the foreground, so send first and treat the reveal as best effort. + // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) : send; @@ -1034,8 +1010,7 @@ export const make = Effect.gen(function* () { }), runMainContentsCommand: Effect.fn("desktop.window.runMainContentsCommand")(function* (command) { yield* Effect.annotateCurrentSpan({ command }); - // The registered main window, never the focused one: with an OAuth popup - // focused, Reload would otherwise reload the popup mid sign-in. + // A focused OAuth popup must not receive the app's Reload command. const window = yield* electronWindow.main; if (Option.isNone(window) || window.value.isDestroyed()) return; const webContents = window.value.webContents; diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index b87ae9ceab29a..f7db33f17ed5e 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -4,11 +4,9 @@ import type { QuitConfirmationMode, QuitShortcutHintEvent } from "@t3tools/contr export const QUIT_HOLD_DURATION_MS = 1200; export const QUIT_DOUBLE_PRESS_MS = 500; -// "Still held" is proven by auto-repeat keydowns, not by the absence of a -// release: macOS suppresses a letter keyUp while the command key is down, so a -// tap release can go completely unseen and a release-based timer would quit -// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after -// repeats stop so they cannot reach the next app. +// Auto-repeat keydowns prove the hold: macOS drops a letter keyUp while Command is down, +// so a release-based timer would quit anyway. Once held, quitting waits for Q keyUp or a +// quiet grace period so trailing repeats cannot reach the next app. const QUIT_HOLD_RELEASE_GRACE_MS = 600; // A slow repeat rate can exceed the fixed grace. Waiting for two observed // cadences keeps the timer behind the next repeat without slowing normal rates. @@ -46,9 +44,7 @@ export function makeQuitShortcutHandler( let lastPressAt = 0; let lastRepeatAt = 0; let repeatCadenceMs = 0; - // Incremented when a press is superseded or explicitly cancelled. A plain - // key release does not invalidate its pending mode read: a direct-mode - // press must still quit after that read settles. + // A key release must not invalidate a pending direct-mode read; only superseding/cancelled presses do. let generation = 0; const clearWatchdog = () => { @@ -125,8 +121,7 @@ export function makeQuitShortcutHandler( if (!modifierDown || input.alt || input.shift || key !== "q") { if (key === modifierKey && !input.alt && !input.shift) return; - // Keep this separate from release(), which also runs when a fresh Q - // keydown follows a keyUp that macOS did not deliver. + // release() also runs on fresh keydown after a missing macOS keyUp. if (!input.isAutoRepeat) { lastPressAt = 0; release(); diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index d1f61ecc0bded..e77d896be2469 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -15,8 +15,6 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "./DesktopWslEnvironment.ts"; -// Exported so callers that parse pool ids (e.g. the pickFolder IPC handler in -// ipc/methods/window.ts) reference the same prefix this module produces. export const WSL_INSTANCE_ID_PREFIX = "wsl:"; const WSL_DEFAULT_DISTRO_ID = `${WSL_INSTANCE_ID_PREFIX}default`; const MAX_TCP_PORT = 65_535; @@ -43,9 +41,7 @@ const isWslInstanceId = (id: DesktopBackendPool.BackendInstanceId): boolean => const buildLabel = (distro: string | null): string => distro === null ? "WSL (default distro)" : `WSL (${distro})`; -// Loopback-only port scan starting one above the primary's port. The -// WSL backend is reachable via 127.0.0.1 from Windows (wslhost -// auto-forwards), so we only need to verify the IPv4 loopback can bind. +// WSL auto-forwards loopback ports, so only IPv4 loopback availability matters. const scanForWslPort = Effect.fn("desktop.wslBackend.scanForWslPort")(function* ( startPort: number, ): Effect.fn.Return { @@ -69,11 +65,7 @@ export const layer = Layer.effect( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const net = yield* NetService.NetService; - // Serialize reconcile so the bootstrap fork and the IPC handlers - // (setWslBackendEnabled, setWslDistro) can't interleave. Without - // this, two reconciles could both observe "no WSL instance - // registered" between their pool reads and both call startNew - // with different distros, leaving the loser stranded. + // Serialize bootstrap and IPC reconciles so concurrent reads cannot register competing WSL instances. const reconcileMutex = yield* Semaphore.make(1); const preflightErrorRef = yield* Ref.make(Option.none()); @@ -169,11 +161,7 @@ export const layer = Layer.effect( const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); - // In wsl-only mode the pool's primary IS the WSL backend (see - // DesktopBackendConfiguration.resolvePrimary), so the - // orchestrator skips registering a parallel "wsl:" - // secondary. Without this skip we'd spin up two WSL processes - // on the same distro for users who explicitly asked for one. + // In wsl-only mode WSL is already primary; do not start a second backend on the same distro. const shouldRun = settings.wslBackendEnabled && available && !settings.wslOnly; const targetId = shouldRun ? Option.some(resolveTargetInstanceId(settings.wslDistro)) @@ -210,8 +198,7 @@ export const layer = Layer.effect( } if (Option.isSome(targetId)) { - // Pre-warm the WSL VM before registering so the readiness probe doesn't - // race wsl.exe's first-spawn cold start. + // Pre-warm WSL so readiness cannot race its cold start. yield* wslEnvironment.preWarm(settings.wslDistro); yield* startNew({ distro: settings.wslDistro }); } diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 711733cb36b54..67549f6b8a54f 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -39,8 +39,7 @@ const posixShellRunner = (() => { const runShell = (script: string) => { if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); - // The install script arrives on stdin in production too, which is what lets - // its own /proc scan not match itself. + // Pass the script on stdin, as production does, to avoid matching its own /proc scan. const result = NodeChildProcess.spawnSync( posixShellRunner.file, [...posixShellRunner.args, "-s"], @@ -54,10 +53,7 @@ const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const awaitHolder = (condition: string) => `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; -// Holds a cache busy the way a running server does: the holder's argv[0] is the -// runtime entry, which the prune and install scripts look for in /proc cmdline. -// It reads a pipe the script keeps open on fd 7, so it exits with the script -// instead of outliving it on a timer. +// Use runtime-entry argv[0] for /proc detection; an open pipe keeps it alive only while this script runs. const holdRuntimeBusy = (entry: string) => [ `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, @@ -124,8 +120,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed const archivePath = `${work}/wsl-runtime.tar.gz`; const archiveSha = readField(result.stdout, "archiveSha"); const runtimeId = `sha256-${archiveSha}`; - // The script reads $HOME, and WSL does not inherit the parent process's - // environment, so the home override rides in the script itself. + // WSL does not inherit HOME; embed the fixture's override in the script. const installScript = (archive = archivePath, sha = archiveSha) => [ `HOME=${sh(`${work}/home`)}`, @@ -288,8 +283,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, fixture.installScript(), "T3CODE_INSTALL_SCRIPT", - // Both racers run the same file, and neither file path contains the - // runtime root, so the script's own /proc scan cannot see them. + // Keep runtime roots out of both racers' argv so their /proc scans cannot match themselves. 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', "first=$!", 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 12d66660ab9c8..5017793d07357 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -89,9 +89,7 @@ export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect; - // Best-effort enumeration for renderer UX. Backend health checks must use - // probeDistros so a transient command failure is not mistaken for a - // successful empty installation. + // UX-only best effort; health checks need probeDistros to distinguish failures from an empty installation. readonly listDistros: Effect.Effect; readonly probeDistros: Effect.Effect; readonly preWarm: (distro: string | null) => Effect.Effect; @@ -100,11 +98,7 @@ export class DesktopWslEnvironment extends Context.Service< windowsPath: string, ) => Effect.Effect>; readonly getUserHome: (distro: string | null) => Effect.Effect>; - // The orchestrator uses this for the WSL backend's httpBaseUrl so the - // renderer can reach it without relying on wslhost's localhost→WSL - // automatic forwarding, which is flaky in practice (the backend can be - // listening for 30+ seconds before wslhost starts forwarding 127.0.0.1:port - // to WSL-side localhost). + // Use the distro IP to avoid unreliable wslhost localhost forwarding. readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, @@ -177,8 +171,7 @@ const buildWslNodeEnvPreamble = ( ensure_remote_node_path || true `; -// wsl.exe re-escapes args before forwarding them to the Linux side, which -// mangles quotes inside `bash -lc " ", + "

The moon garden opens at dusk

", + '', + ].join(""), + }); + expect(preview.consoleMessages).toContainEqual({ + level: "log", + text: appearance === "light" ? "false rgb(24, 24, 27)" : "true rgb(250, 250, 250)", + }); + } + }).pipe(Effect.provide(layerHtmlRender(executable))); + }), + ); + it.live("keeps every local file but the page itself out of the browser", (ctx) => Effect.gen(function* () { const executable = (yield* HostProcess.Environment)[TEST_BROWSER_ENV]; diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index 68f4fd6657711..cd3a7f3abe582 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -78,11 +78,8 @@ export class HtmlRenderViewportHeightError extends Schema.TaggedError|$)|]*(?:>|$)([\s\S]*?)(?:<\/style\s*>|$)|\sstyle\s*=\s*(?:"([^"]*)(?:"|$)|'([^']*)(?:'|$)|([^\s>]+))/gi; // The lookbehind skips base64 runs, such as an inlined image that happens to contain "1vh". @@ -182,10 +179,7 @@ const dataUriPrefix = (path: string) => const latin1 = (bytes: Uint8Array, start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end)); -/** - * Whether file bytes are an image, whatever the file is named, so a symlink or - * renamed file cannot carry other data, such as a secret, into a page. - */ +// Validate bytes, not filenames, so renamed files or symlinks cannot inline secrets. const isImageBytes = (bytes: Uint8Array) => { const head = latin1(bytes, 0, 12); if ( @@ -273,8 +267,7 @@ const inlineLocalImages = Effect.fn("HtmlRender.inlineLocalImages")(function* (h if (pageBytes > MAX_PAGE_BYTES) { return yield* new HtmlRenderPageTooLargeError({ sizeBytes: pageBytes }); } - // Files can grow after `stat`. Each read stops one byte past the image - // limit, and reading stops once the images read so far cannot fit the page. + // Files can grow after stat; bound each read and stop when accumulated images cannot fit. let readBytes = 0; const images = yield* Effect.forEach( files.filter((file) => file.size !== undefined), @@ -423,8 +416,7 @@ const make = Effect.gen(function* () { FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner >(); const browsers = yield* Semaphore.make(MAX_CONCURRENT_BROWSERS); - // Chrome's sandbox stays on unless the operator explicitly turns it off. - // Chrome also refuses it as root, where that opt-out is the only way to run. + // Keep Chrome sandboxed unless explicitly disabled; root must opt out to run. const noSandbox = PreviewBrowserHost.sandboxDisabled(yield* HostProcess.Environment); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -581,6 +573,7 @@ const make = Effect.gen(function* () { html: inlined.html, width, urlFragment: htmlRenderThemeFragment(theme), + appearance, }).pipe(Effect.provideContext(services)), ); return { diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index d9339b9c46946..5562d43826c76 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -1,3 +1,4 @@ +import type { ThemeAppearance } from "@t3tools/shared/themePalettes"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -45,14 +46,8 @@ const MAX_CONSOLE_TEXT_CHARS = 500; const VIEWPORT_HEIGHT = 800; const MAX_CAPTURE_HEIGHT = 4_000; const CAPTURE_TIMEOUT = "20 seconds"; -// Chrome refuses local files to every web page, frame, worker, and popup, so a -// page cannot read files the agent's provider withholds; local images reach it -// already inlined as data URIs. `.localhost` keeps it a secure context that may -// still load plain-http resources, and the request never leaves the browser. -// All of the browser's traffic goes through `publicProxy`, which only reaches -// public addresses, so a page cannot reach this machine's local network. The -// main frame also stays on the page and the browser opens no popups, so a -// capture always shows the page itself. +// Chrome blocks file access; images arrive inlined. Browser-only .localhost permits secure contexts and HTTP resources. +// publicProxy blocks local networks; pinning the main frame and forbidding popups keeps captures on the page. const PAGE_ORIGIN = "http://t3-page.localhost"; const PAGE_URL = `${PAGE_ORIGIN}/page.html`; const PAGE_NAME = "page.html"; @@ -60,9 +55,7 @@ const MEASURE_CONCURRENCY = 3; const textEncoder = new TextEncoder(); -/** - * Node's encoder, since pages run to 25 MiB. - */ +// Pages reach 25 MiB; use Node's encoder. const pageBody = (html: string) => Buffer.from(Buffer.from(html, "utf8").toString("base64"), "latin1"); @@ -151,11 +144,10 @@ const consoleMessageFromEvent = (method: string, params: unknown): ConsoleMessag return undefined; }; -// Resolves after web fonts load and two frames paint, so late layout lands in the capture. +// Wait for fonts and two paint frames to capture late layout. const SETTLE_EXPRESSION = "document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)))))"; -// The root's scroll height never drops below the viewport, so a short page -// reports its own box height instead. +// scrollHeight is at least viewport height; use the root box for shorter pages. const MEASURE_EXPRESSION = "(() => { const root = document.documentElement; return root.scrollHeight > root.clientHeight ? root.scrollHeight : root.getBoundingClientRect().height; })()"; @@ -174,10 +166,7 @@ interface PageEvents { omittedConsoleMessages: number; } -/** - * Starts the headless shell for the life of the scope, speaking CDP over - * `--remote-debugging-pipe` (fd 3 in, fd 4 out, NUL-delimited JSON). - */ +// CDP pipe uses fd 3 in, fd 4 out and NUL-delimited JSON. const launchBrowser = Effect.fnUntraced(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -266,9 +255,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ), ).pipe(Effect.asVoid); - // Serves the page. Its body is shared bytes between two small JSON halves, - // queued together, so loading a page of up to 25 MiB at several widths never - // copies it on this side. + // Queue shared body bytes between JSON halves to avoid copies across capture widths. const fulfillPage = (sessionId: string, requestId: string, body: Uint8Array) => Queue.offerAll(outgoing, [ textEncoder.encode( @@ -308,8 +295,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { if (url === PAGE_URL && body !== undefined) { return fulfillPage(sessionId, paused.requestId, body); } - // A frame inside the page may show another site; Local Network Access - // still covers it. The main frame and T3's own origin serve nothing else. + // Subframes may show other sites covered by Local Network Access; the main frame stays on T3's page. const otherSiteFrame = paused.resourceType === "Document" && paused.frameId !== page.mainFrameId && @@ -337,8 +323,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { return Effect.void; }; - // The pipe closes when the browser exits. A startup abort, such as a missing - // sandbox, says why on stderr, which may still be draining, so give it a moment. + // Startup aborts close the pipe; let stderr drain to recover the reason. const disconnect = Effect.gen(function* () { yield* Fiber.await(stderrReader).pipe(Effect.timeout("1 second"), Effect.ignore); const exit = yield* child.exitCode.pipe( @@ -367,7 +352,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - // Decoded text chunks may split a message anywhere, and a screenshot reply spans many. + // Chunks may split messages; screenshot replies span many chunks. let partial: Array = []; yield* child.getOutputFd(4).pipe( Stream.decodeText(), @@ -413,7 +398,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); - const openPage = Effect.fnUntraced(function* (width: number) { + const openPage = Effect.fnUntraced(function* (width: number, appearance: ThemeAppearance) { const { targetId } = yield* send( "Target.createTarget", { url: "about:blank" }, @@ -436,6 +421,12 @@ const launchBrowser = Effect.fnUntraced(function* (input: { yield* send("Page.enable", {}, Ignored, sessionId); yield* send("Runtime.enable", {}, Ignored, sessionId); yield* send("Log.enable", {}, Ignored, sessionId); + yield* send( + "Emulation.setEmulatedMedia", + { features: [{ name: "prefers-color-scheme", value: appearance }] }, + Ignored, + sessionId, + ); // Previews have no use for WebRTC, whose ICE servers can make the browser // resolve names outside the proxy. Runs before page scripts in every frame. yield* send( @@ -447,8 +438,7 @@ const launchBrowser = Effect.fnUntraced(function* (input: { Ignored, sessionId, ); - // Pauses T3's own origin, to serve the page, and every document, to keep - // the main frame on it. + // Intercept T3's page and all document navigations to keep the main frame on it. yield* send( "Fetch.enable", { @@ -550,6 +540,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen readonly html: string; readonly width: number; readonly urlFragment: string; + readonly appearance: ThemeAppearance; }) { const path = yield* Path.Path; const directory = yield* scratchDirectory; @@ -558,7 +549,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen noSandbox: input.noSandbox, profileDirectory: path.join(directory, "profile"), }); - const page = yield* browser.openPage(input.width); + const page = yield* browser.openPage(input.width, input.appearance); const contentHeight = yield* page.load(pageBody(input.html), input.urlFragment); const capturedHeight = Math.max(1, Math.min(contentHeight, MAX_CAPTURE_HEIGHT)); const png = yield* page.screenshot(capturedHeight); @@ -576,10 +567,7 @@ export const captureHtmlScreenshot = Effect.fn("headlessChrome.captureHtmlScreen }), ); -/** - * Content heights of the page at each width, each from a fresh load, since - * pages often lay themselves out from the width once at load. - */ +/** Fresh load per width, since pages may lay out only at load. */ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights")(function* (input: { readonly executable: string; readonly noSandbox: boolean; @@ -598,7 +586,7 @@ export const measureHtmlHeights = Effect.fn("headlessChrome.measureHtmlHeights") return yield* Effect.forEach( input.widths, (width) => - browser.openPage(width).pipe( + browser.openPage(width, "dark").pipe( Effect.flatMap((page) => page.load(body, input.urlFragment).pipe(Effect.ensuring(page.close)), ), diff --git a/apps/server/src/htmlRender/publicProxy.ts b/apps/server/src/htmlRender/publicProxy.ts index ce6b7796fe138..e73135e5a8f10 100644 --- a/apps/server/src/htmlRender/publicProxy.ts +++ b/apps/server/src/htmlRender/publicProxy.ts @@ -19,8 +19,7 @@ for (const [network, prefix] of [ ] as const) { LOCAL_ADDRESSES.addSubnet(network, prefix, "ipv4"); } -// Also the IPv6 ranges that carry an IPv4 address a translator may route to -// without checking it: IPv4-compatible, local-use NAT64, and Teredo. +// Block IPv4-carrying IPv6 ranges that translators may route without checking. for (const [network, prefix] of [ ["::", 96], ["64:ff9b:1::", 48], @@ -52,11 +51,7 @@ const ipv6Groups = (address: string) => { ); }; -/** - * The IPv4 address a NAT64 or 6to4 address stands for, which a translator - * will route to, so it is checked as well. Public NAT64 targets stay - * reachable, as on IPv6-only networks. - */ +// Check the translated IPv4 too, while allowing public NAT64 targets on IPv6-only networks. const embeddedIPv4 = (address: string) => { const at = NAT64.check(address, "ipv6") ? 6 : SIX_TO_FOUR.check(address, "ipv6") ? 1 : -1; if (at === -1) return undefined; @@ -81,10 +76,7 @@ const isLocal = (address: string, family: number): boolean => { return embedded !== undefined && isLocal(embedded, 4); }; -/** - * The caller connects only to addresses checked here, so a name that later resolves elsewhere - * (DNS rebinding) changes nothing. - */ +// Connect only to checked addresses to prevent DNS rebinding. const publicAddresses = async (host: string) => { const addresses = NodeNet.isIP(host) ? [{ address: host, family: NodeNet.isIP(host) }] @@ -121,10 +113,7 @@ const readRequest = (data: Buffer) => { return { command: data[1], host, port: data.readUInt16BE(end - 2), rest: data.subarray(end) }; }; -/** - * Chrome's Local Network Access misses some requests, such as speculation-rules prefetches; - * nothing misses this. - */ +/** Covers requests Chrome's Local Network Access misses, including speculation prefetches. */ export const publicProxy = Effect.acquireRelease( Effect.callback< { readonly server: NodeNet.Server; readonly sockets: Set }, @@ -171,8 +160,7 @@ export const publicProxy = Effect.acquireRelease( client.off("data", holdEarly); if (addresses === undefined) return refuse(client, 2); client.pause(); - // Tries every checked address, IPv6 and IPv4 alike, so a host is - // still reached on a network whose IPv6 route is broken. + // Try all checked addresses so broken IPv6 routes do not block IPv4 access. const upstream = track( NodeNet.connect({ host: request.host, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 48c75a1240351..daefb3b913508 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -148,9 +148,7 @@ const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (sta ServerHttp.layerStaticAndDevRoute, ServerHttp.layerHttpCompression, ).pipe(Layer.provideMerge(NodeHttpPlatform.layer)); - // HttpRouter.serve reuses services from the layers around it before the - // app's own, and NodeHttpServer.layerTest brings a real FileSystem, so the - // caller's FileSystem and static directory go between the two. + // HttpRouter.serve prefers outer services; inject the test FileSystem between it and NodeHttpServer.layerTest. const services = yield* Layer.build( HttpRouter.serve(layerApp, { disableListenLog: true }).pipe( Layer.provideMerge(ServerConfig.layer({ ...config, staticDir })), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 25bd838e84fcb..188120f3c0ffe 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -61,15 +61,12 @@ import { const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; -// The sandbox gives the document an opaque origin: scripts run, but same-origin -// cookies, storage, and API calls are out of reach. No modals: agent HTML can -// open without a click (inline renders, and mobile loads it as the top document), -// and must not raise blocking dialogs. +// Opaque origin blocks cookies, storage and API access while allowing scripts. +// Agent HTML can open without a click, so disallow blocking modals. const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-downloads"; -// Types a browser may render as a document if a proxy strips the disposition -// header. Downloads of these fall back to octet-stream. +// Proxies may strip disposition; force document-capable downloads to octet-stream. const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; const isSafeDownloadMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && @@ -199,9 +196,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); if (isMedia) { - // Do not invite conditional range requests with validators that cannot establish - // byte-for-byte identity. Attachment media carries no `file`, and must not - // outlive the signed URL that granted it either. + // Without file identity, validators cannot guarantee byte equality; attachments must also expire with their URL. headers["Cache-Control"] = "private, no-store"; } let status = 200; @@ -262,8 +257,7 @@ export const layerBrowserApiCors = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; - // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be - // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. + // Credentialed dev requests need explicit Vite/Electron origins; packaged desktop uses the default wildcard. return HttpRouter.cors({ ...(devOrigin ? { @@ -336,8 +330,7 @@ class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecor readonly cause: unknown; }> {} -// Renderers export up to once a second while they have spans buffered, so -// tracing this proxy would add more server spans than it forwards. +// Tracing once-per-second renderer exports would create more spans than it forwards. export const layerOtlpTracesProxyRoute = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, @@ -400,9 +393,7 @@ const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([ SLACK_SERVER_CALLBACK_PATH, ]); -// TracerDisabledWhen is one predicate for the whole server, and -// HttpRouter.serve builds its routes privately, so it is provided to the served -// layer, never merged into the routes. +// HttpRouter.serve builds routes privately; provide the server-wide TracerDisabledWhen to the served layer. const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => { const queryIndex = request.url.indexOf("?"); const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex); @@ -678,8 +669,7 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( const etag = headers.ETag; const lastModified = headers["Last-Modified"]; - // If-None-Match takes precedence over dates and uses weak comparison for - // GET/HEAD, including when compression changes the transferred bytes. + // If-None-Match precedes dates and uses weak GET/HEAD comparison even across compression. const ifNoneMatch = request.headers["if-none-match"]; const ifModifiedSince = request.headers["if-modified-since"]; const unchanged = diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index ae23fc37a08e8..ed3c7e65c7974 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -87,9 +87,7 @@ describe("guardHttpResponseWriteErrors", () => { }); server.on("upgrade", (_request, socket) => { - // Simulate the client vanishing while the auth rejection response is - // written to the upgrade socket: the write failure surfaces as an - // "error" event on a socket Node's http server no longer listens to. + // Node's HTTP server no longer listens for errors on an upgraded socket. socket.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); }); diff --git a/apps/server/src/httpResponseErrorGuard.ts b/apps/server/src/httpResponseErrorGuard.ts index b243fba20a872..0f215ba797f87 100644 --- a/apps/server/src/httpResponseErrorGuard.ts +++ b/apps/server/src/httpResponseErrorGuard.ts @@ -3,11 +3,6 @@ import type * as NodeHttp from "node:http"; import type * as NodeStream from "node:stream"; -/** - * An "error" event without a listener escalates into an uncaught exception and - * terminates the whole server process, taking every other client and all - * in-flight provider work with it. - */ export function guardHttpResponseWriteErrors( server: T, onError?: (error: unknown) => void, diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index b180dda51a095..338bbd62ce592 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -45,10 +45,7 @@ function isBase64Whitespace(code: number): boolean { return code === 0x0d || code === 0x0a || code === 0x20; } -// Data URLs carry the full image payload, so this parser must never run a -// regex across the payload: V8's regex engine borrows the JS call stack, and -// matching a multi-megabyte string from a deep call stack (e.g. inside fiber -// execution) throws "Maximum call stack size exceeded". +// V8 regexes use the JS stack; scanning image payloads can overflow a deep fiber stack. export function parseBase64DataUrl( dataUrl: string, ): { readonly mimeType: string; readonly base64: string } | null { @@ -100,8 +97,7 @@ export function parseBase64DataUrl( if (base64.length === 0 || base64.length % 4 !== 0) return null; const firstPad = base64.indexOf("="); if (firstPad !== -1) { - // '=' is only valid as one or two trailing padding characters; Node's - // decoder would otherwise silently truncate at the first '='. + // Node silently truncates at the first =; allow only one or two trailing padding characters. if (base64.length - firstPad > 2) return null; for (let index = firstPad; index < base64.length; index += 1) { if (base64.charCodeAt(index) !== 0x3d) return null; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 7fbfdbe9e651c..bf87ee2eabc01 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -548,8 +548,7 @@ const make = Effect.gen(function* () { } } - // Keep only defaults that fit, before persisted rules so existing - // custom shortcuts retain priority when their conditions overlap. + // Put defaults before persisted rules so custom shortcuts win overlapping conditions. const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); const defaultsToAppend = missingDefaults.slice(0, availableSlots); const skippedDefaults = missingDefaults.slice(availableSlots); @@ -594,8 +593,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( Stream.filter((event) => { return ( diff --git a/apps/server/src/launcherWatch.ts b/apps/server/src/launcherWatch.ts index d887028024741..ca109422c95a8 100644 --- a/apps/server/src/launcherWatch.ts +++ b/apps/server/src/launcherWatch.ts @@ -4,22 +4,13 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_POLL_INTERVAL = Duration.seconds(2); -// Sampled when this module loads, before config resolution and the bootstrap -// read, so a launcher that dies during startup is still noticed. +// Capture before config and bootstrap reads to detect launcher loss during startup. const startupParentPid = process.ppid; /** - * Completes when the process that launched this backend is gone. - * - * A desktop-launched backend reads its bootstrap envelope once and never looks - * at its launcher again. When the launcher dies without a graceful stop (a WSL - * relay killed during an app update, for instance), the backend would keep - * serving and holding its ownership lock while no desktop can reach it, and - * every replacement launch is refused. An orphan is adopted by init or, on - * Linux, by a subreaper, so any change of parent counts rather than only a - * move to pid 1. A backend that already starts under init has no launcher to - * lose, and Windows keeps the original parent id after the parent exits, so - * neither is watched. + * Any parent change signals launcher loss, including adoption by a Linux subreaper; + * otherwise an orphan backend keeps its ownership lock and blocks replacement launches. + * Skip init-launched backends and Windows, which retains the exited parent's id. */ export const awaitLauncherLoss = Effect.fn("launcherWatch.awaitLauncherLoss")(function* (options?: { readonly launcherPid?: number; diff --git a/apps/server/src/mcp/AcpMcpStdioBridge.ts b/apps/server/src/mcp/AcpMcpStdioBridge.ts index ea9a4c93b00f7..2e7c22ce00405 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -1,5 +1,4 @@ -// The bridge relays opaque JSON-RPC lines verbatim; schema-decoding foreign -// payloads here would reject traffic it must pass through untouched. +// Relay JSON-RPC verbatim; schema decoding would reject foreign payloads. // @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off import * as NodeReadline from "node:readline"; @@ -10,10 +9,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { discardResponseBody, responsePayloads } from "@t3tools/shared/mcpResponsePayloads"; -/** - * ACP agents must support stdio MCP servers, while optional http/sse support is - * unevenly implemented. - */ +/** ACP mandates stdio MCP support; HTTP/SSE support varies. */ export class AcpMcpBridgeError extends Error { readonly _tag = "AcpMcpBridgeError"; @@ -253,10 +249,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. ), ); - // Only session establishment is ordered; client responses to - // server-initiated SSE requests must bypass the barrier or they would - // deadlock the still-open HTTP exchange, and normal MCP calls stay - // concurrent after initialization. + // Order only session establishment; SSE responses must bypass it to avoid deadlock. Calls remain concurrent after initialization. let sessionBarrier: Effect.Effect = Effect.void; let sessionBarrierPending = false; @@ -327,10 +320,7 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. }); } -/** - * Kept free of heavy imports: these commands run on the ACP first-message - * critical path. - */ +/** Avoid heavy imports on the ACP first-message path. */ export async function runAcpMcpCliFastPath( command: "acp-mcp-bridge" | "acp-mcp-call", args: ReadonlyArray, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1b915d2d030c9..0ee031e4cddbd 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -135,7 +135,6 @@ const callTool = (name: string, args: Record) => const callSnapshot = (args: Record) => callTool("preview_snapshot", args); -// Real tool definitions with handlers that record what they decoded. const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, @@ -544,7 +543,6 @@ it.effect("decodes null optional arguments the way it decodes omitted ones", () yield* callTool("t3_thread_read", { ...thread, afterPosition: null, view: null }); yield* callTool("delegate_task", { task: "Inspect", target: {} }); yield* callTool("delegate_task", { task: "Inspect", title: null, target: { model: null } }); - // A null the schema declares keeps its meaning: here it clears the project icon. yield* callTool("t3_project_update", { projectId: "project-1", projectIcon: null }); expect(calls).toHaveLength(5); @@ -661,6 +659,14 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it("starts each delegated review round with delegate_task", () => { + const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; + expect(description).toContain("For every T3 delegated review round, call delegate_task again"); + expect(description).toContain( + "The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send.", + ); +}); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => @@ -772,7 +778,6 @@ 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"); - // 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"]), @@ -1200,3 +1205,90 @@ it.effect("admits provider and OAuth client credentials and points only clients }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("lists pull request watch tools only to threads that are not delegated children", () => + Effect.scoped( + Effect.gen(function* () { + const scopeFor = ( + token: string, + subagent: boolean, + ): McpInvocationContext.McpThreadInvocationScope => ({ + environmentId, + requestNamespace: token, + thread: { + threadId: ThreadId.make(`thread-${token}`), + providerSessionId: token, + providerInstanceId: ProviderInstanceId.make("codex"), + subagent, + }, + client: undefined, + capabilities: new Set(["pull-requests"]), + issuedAt: 1, + }); + const scopes = new Map([ + ["parentToken", scopeFor("parentToken", false)], + ["childToken", scopeFor("childToken", true)], + ]); + const serverLayer = McpHttpServer.layerPullRequestsToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerMcpTransport), + Layer.provide( + Layer.mock(McpSessionRegistry.McpSessionRegistry)({ + resolve: (token) => Effect.succeed(scopes.get(token)), + }), + ), + Layer.provide( + Layer.mergeAll( + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}), + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), + NodeServices.layer, + ), + ), + ); + yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( + Layer.build, + ); + const httpClient = yield* HttpClient.HttpClient; + const listedTools = (token: string) => + Effect.gen(function* () { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${token}`, + }; + const initialize = yield* httpClient.post("/mcp", { + headers, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + }); + const listed = yield* httpClient.post("/mcp", { + headers: { + ...headers, + "mcp-session-id": initialize.headers["mcp-session-id"]!, + "mcp-protocol-version": "2025-06-18", + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, + "application/json", + ), + }); + const body = (yield* listed.json) as { + readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; + }; + return body.result.tools.map((tool) => tool.name); + }); + + const parentTools = yield* listedTools("parentToken"); + const childTools = yield* listedTools("childToken"); + expect(parentTools).toEqual( + expect.arrayContaining(["link_pull_request", "watch_pull_request", "unwatch_pull_request"]), + ); + expect(childTools).toContain("link_pull_request"); + expect(childTools).not.toContain("watch_pull_request"); + expect(childTools).not.toContain("unwatch_pull_request"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 05705c751a596..e6c1907439a12 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -79,11 +79,7 @@ const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => Option.map((url) => `${url.origin}/.well-known/oauth-protected-resource/mcp`), ); -/** - * Agents T3 Code launched carry a registry token and must never be sent into an - * OAuth flow when it dies: they cannot open a browser, and a sign-in would mint - * a credential that outlives their session. - */ +/** Launched agents cannot open OAuth browsers; OAuth would mint credentials outliving their session. */ const unauthorized = (input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presentedToken: boolean; @@ -190,13 +186,7 @@ const layerMcpAuthMiddleware = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; -/** - * 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. - */ +/** Claude Code spills oversized results to files, losing locators; bound both text and its preferred structuredContent. */ export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; @@ -281,8 +271,7 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { actionTimeline: tail(metadata.actionTimeline, "action timeline entries"), }; - // Per-field caps do not sum below the ceiling: three log arrays of 40 capped - // entries alone can pass 60 KB, and the caps count characters, not bytes. + // Per-field character caps can still exceed the total byte ceiling. const shedOrder = [ "actionTimeline", "networkEntries", @@ -331,7 +320,6 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { } 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 ? [] @@ -366,7 +354,6 @@ const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { const decodeAsAdvertised = >( toolkit: Toolkit.Toolkit, ): Toolkit.Toolkit => - // The clones keep each tool's id, name and decoded parameter type, so handlers still match. Toolkit.make( ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toCodecJson(tool.parametersSchema)), @@ -388,8 +375,7 @@ 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. + // Preview errors contain server-authored recovery guidance, never page output. const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, @@ -553,11 +539,7 @@ interface ImageToolResult { readonly [key: string]: unknown; } -/** - * Failures surface only their tag unless the tool describes them: the remote - * message may carry renderer or device output the agent should not see, and the - * tag is what it can act on. - */ +/** Expose only undescribed failures' tags; remote messages may contain renderer or device output. */ const imageToolFailure = (toolName: string, operation: string, failureText: string | ((error: E) => string)) => (cause: Cause.Cause) => { @@ -788,15 +770,7 @@ const handoffTargetIds = ( return typeof threadId === "string" ? [threadId] : []; }; -/** - * Runs a tool registration against a server that records an anonymous - * `mcp.tool.invoked` event for every handoff call and every failed call of - * other tools: the tool, the calling agent's provider, the outcome with its - * failure code, and the duration. A handoff tool also - * reports the caller's model, origin, and delegation depth, the settings the - * agent chose, and the provider, model, and modes of each thread that received - * the work. Ids, prompts, and free text are never recorded. - */ +/** Tool analytics must never include IDs, prompts or free text. */ const withToolAnalytics = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (registration: Effect.Effect) { diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index f830da3d4a03c..cc45af6bae4df 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; const ALL_MCP_CAPABILITIES = [ "preview", @@ -24,6 +25,8 @@ export interface McpThreadCaller { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; + /** A delegated child task; its parent owns the work it hands back. */ + readonly subagent?: boolean; } export interface McpClientCaller { @@ -32,18 +35,11 @@ export interface McpClientCaller { readonly access: AuthMcpClientAccess; } -/** - * A read-only client never reaches a write (`McpToolAccess` refuses it first), - * so it maps to the lowest mode rather than to nothing. - */ +/** Read-only clients map to the lowest mode because McpToolAccess blocks writes first. */ export const clientRuntimeModeCeiling = (client: McpClientCaller | undefined): RuntimeMode => client === undefined || client.access === "read-only" ? "approval-required" : client.access; -/** - * A thread caller's omitted target falls back to its own thread; a client - * caller has no own thread, so tools that act as the caller (delegate_task, - * preview, worktree handoff) need `thread`. - */ +/** Thread callers default to their own thread; client callers need thread for tools that act as the caller. */ export interface McpInvocationScope { readonly environmentId: EnvironmentId; readonly capabilities: ReadonlySet; @@ -58,6 +54,16 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} +/** + * `McpSchema.EnabledWhen` for tools a delegated child must not see or call. + * EnabledWhen only receives the MCP client profile, so read the T3 caller from the request fiber. + */ +export const hiddenFromSubagents = (): boolean => { + const fiber = Fiber.getCurrent(); + const scope = fiber && Context.getOrUndefined(fiber.context, McpInvocationContext); + return scope?.thread?.subagent !== true; +}; + export type McpCapabilityError = C extends "preview" ? PreviewAutomationUnavailableError : McpCapabilityUnavailableError; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index a6408d8e12cfb..d583bbbbe0c4f 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -15,13 +15,10 @@ import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - /** - * When false, the credential is minted without the "preview" capability so - * the user's choice to withhold agent browser access holds everywhere the - * token is honored (#7083). Defaults to full access. - */ + /** False omits the preview capability wherever the token is honored. Defaults to full access. */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + readonly subagent?: boolean; } export interface McpIssuedCredential { @@ -116,6 +113,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + subagent: request.subagent === true, }, client: undefined, capabilities: new Set([ diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa0406..ecfb8a9993b30 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -216,7 +216,6 @@ const supervised = threadCaller(supervisedThreadId); const plan = threadCaller(planThreadId); const fullAccess = threadCaller(fullAccessThreadId); const ended = threadCaller(endedThreadId); -// Its turn ended, but a background subagent it started still runs. const background = threadCaller(backgroundThreadId); const oldProviderBackground = threadCaller(oldProviderThreadId); const archivedBackground = threadCaller(archivedThreadId); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index 5cc8dc0c38b8f..9d99fb7da89e3 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -27,10 +27,7 @@ import { unavailable, } from "./threadAccess.ts"; -/** - * Their private constructors only stop the type checker; this also stops - * `Reflect.construct` and friends at runtime. - */ +/** Private constructors only enforce types; the token also blocks runtime construction. */ const builtHere: unique symbol = Symbol("t3/mcp/McpToolAccess/builtHere"); const refuseOutsideConstruction = (token: symbol) => { @@ -55,11 +52,7 @@ let handlersLayer: , EX, RX>( layer: Layer.Layer, EX, RX>, ) => HandlersLayer; -/** - * Effect's own registration functions accept any handler, so the - * `t3code/no-raw-mcp-registration` lint rule keeps Effect's `McpServer` inside - * McpHttpServer. - */ +/** t3code/no-raw-mcp-registration confines Effect's unchecked registration API to McpHttpServer. */ export class Declaration { readonly #handle: Handler; private constructor(token: typeof builtHere, handle: Handler) { @@ -117,10 +110,7 @@ export const actsAsCaller = (handle: (params: P) => Effect.Effect(handle: (params: P) => Effect.Effect) => declare((params: P) => orchestratingCaller.pipe(Effect.flatMap(() => handle(params)))); -/** - * Unlike `writes` it does not need thread control; the handler checks its - * capability, as the capability toolkits do. - */ +/** Requires handler capability checks, not thread control. */ export const writesWithCapability = (handle: (params: P) => Effect.Effect) => declare((params: P) => writingCaller.pipe(Effect.flatMap(() => handle(params)))); @@ -140,9 +130,7 @@ export const writesThreads = ( yield* assertTargetWithinLimits(caller.limits, target); } } - // The target's user can raise its modes after the check above; the - // orchestrator checks again under the thread's lock and records its - // refusal here, since handlers wrap dispatch errors their own way. + // Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors. const refused = yield* Ref.make(undefined); return yield* handle(params).pipe( Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }), @@ -196,10 +184,7 @@ export const startsThreads = ( const fullAccessRequired = "Changing projects or environment settings needs a live full-access/default calling thread or a full-access client."; -/** - * `check` re-checks the caller wherever the handler waits before writing, such - * as for a lock, since the caller's modes can change meanwhile. - */ +/** Recheck after waits before writing; caller modes may have changed. */ export const writesEnvironment = ( handle: ( params: P, diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 76f55d233dc94..6e70405230e1b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -525,7 +525,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(staleDelete.code).toBe("parent_not_active"); - // A background item keeps only its owning instance live, even after a switch. const backgroundTasks = [ { taskId: "background-subagent", @@ -569,7 +568,6 @@ it("readThread and sendToThread reach threads in other projects", async () => { .pipe(Effect.flip); expect(mismatched.code).toBe("parent_not_active"); - // A fresh active run wins over the earlier inactive projection and empty roster. parentShell = liveThreadShell(parentThreadId); const restarted = yield* service.sendToThread(makeScope(), { threadId: foreignThreadId, diff --git a/apps/server/src/mcp/OrchestratorMcpService.spawn.test.ts b/apps/server/src/mcp/OrchestratorMcpService.spawn.test.ts index c81ec7c58b956..20db26de30fd5 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.spawn.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.spawn.test.ts @@ -117,7 +117,7 @@ const run = ( threadId === parentThreadId ? parentProjection : ({ - thread: { id: threadId, title: "Child" }, + thread: { id: threadId, title: "Child", modelSelection }, runs: [], } as unknown as OrchestrationV2ThreadProjection), ), diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index a01e8ffcd0e51..224b3be20f79e 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, @@ -92,7 +93,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: childRunId, @@ -227,7 +228,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: RunId.make("run:mcp-restart-child"), @@ -297,6 +298,114 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("tells the parent to end its turn only while its task still runs", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-next-parent"); + const childThreadId = ThreadId.make("thread:mcp-next-child"); + const taskId = NodeId.make("node:mcp-next-task"); + let finished = false; + let pendingRequest = false; + const parentProjection = () => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: finished ? "completed" : "running", + result: finished ? "done" : null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const childRun = () => ({ + id: RunId.make("run:mcp-next-child"), + ordinal: 1, + status: finished ? ("completed" as const) : ("running" as const), + delegatedTaskId: taskId, + }); + const childProjection = () => + ({ + thread: { id: childThreadId, title: "Child task" }, + runs: [childRun()], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], + nodes: [], + turnItems: [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getProjectThreadRecords: () => Effect.succeed(childProjection()), + waitForThread: () => + Effect.succeed({ + threadId: childThreadId, + run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], + timedOut: !finished, + }), + dispatch: () => Effect.succeed({} as never), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-next"), + requestNamespace: "provider-session:mcp-next", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-next", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const running = yield* service.taskStatus(scope, taskId); + assert.equal(running.status, "running"); + assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + const waiting = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(waiting.timedOut); + assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + + // A child waiting on a question or approval needs an answer, not a wake. + pendingRequest = true; + const asking = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(asking.timedOut); + assert.notProperty(asking, "next"); + pendingRequest = false; + + finished = true; + const completed = yield* service.taskStatus(scope, taskId); + assert.equal(completed.status, "completed"); + assert.notProperty(completed, "next"); + const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.equal(waited.status, "completed"); + assert.notProperty(waited, "next"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); @@ -321,7 +430,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], @@ -404,9 +513,9 @@ describe("OrchestratorMcpService", () => { }, ], }) as unknown as OrchestrationV2ThreadProjection; - // A finished run whose binding to the follow-up was lost (#19002). + // A finished run whose binding to the follow-up was lost. const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: childState === "no runs" ? [] @@ -509,7 +618,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -590,7 +699,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -677,7 +786,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -773,7 +882,7 @@ describe("OrchestratorMcpService", () => { [ childThreadId, { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], contextTransfers: [], messages: [], @@ -1009,7 +1118,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e7afed2cb4be4..6c2ba4d75c859 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, TurnItemId, type OrchestrationV2Run, type OrchestrationV2Subagent, @@ -72,6 +73,7 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { describeModelChange, forkAccountChangeLabel, @@ -191,9 +193,7 @@ export interface OrchestratorMcpServiceShape { scope: McpInvocationScope, input: OrchestratorMcpDeleteScheduledTaskInput, ) => Effect.Effect; - /** - * The value never reaches the agent: the result is a status. - */ + /** Only status reaches the agent, never this value. */ readonly requestSecret: ( scope: McpInvocationScope, input: OrchestratorMcpRequestSecretInput, @@ -252,13 +252,7 @@ function failure(code: OrchestratorMcpFailure["code"], message: string): Orchest return new OrchestratorMcpFailure({ code, message }); } -/** - * A caller's thread is live while it is not archived, runs on the caller's - * provider instance, and has an active run or a background subagent that - * instance started. A background subagent outlives its run and calls tools - * with its thread's credential, which is revoked when the provider process - * goes away. - */ +/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ export const isLiveCallerShell = ( shell: Pick< OrchestrationV2ThreadShell, @@ -435,7 +429,6 @@ function providerConstraints( constraints.push("No V2 provider adapter is registered."); } if (provider === undefined) return constraints; - // Driver availability comes from the registry, even when health probes are skipped. if (!isProviderAvailable(provider)) { constraints.push(provider.unavailableReason ?? "Provider driver is unavailable."); } @@ -454,11 +447,7 @@ function providerConstraints( return constraints; } -/** - * Models without descriptors skip the descriptor checks (mirroring how model slugs are only - * validated when the provider advertises models), but duplicate ids always fail: downstream - * consumers disagree on whether the first or last value of a duplicated id wins. - */ +/** Skip checks without descriptors; reject duplicate IDs because consumers disagree on precedence. */ function invalidOptionSelections( selections: ReadonlyArray, descriptors: ReadonlyArray | undefined, @@ -941,6 +930,7 @@ function listItemFromShell( return { threadId: shell.id, title: shell.title, + link: formatThreadLink(shell.id, shell.title), createdBy: shell.createdBy, creationSource: shell.creationSource, status: shell.activityRunStatus ?? shell.status, @@ -973,6 +963,7 @@ function threadDetail( threadId: projection.thread.id, projectId: projection.thread.projectId, title: projection.thread.title, + link: formatThreadLink(projection.thread.id, projection.thread.title), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, status: active?.status ?? latest?.status ?? "idle", @@ -1387,10 +1378,7 @@ const make = Effect.gen(function* () { return { ...caller, target } as const; }); - /** - * A thread caller writes to another thread only while its own run is live, - * so a credential that outlived its session cannot reach across threads. - */ + /** Cross-thread writes require a live caller run; stale session credentials cannot authorize them. */ const assertLiveCallerForOtherThread = ( scope: McpInvocationScope, parent: Pick | undefined, @@ -1466,11 +1454,7 @@ const make = Effect.gen(function* () { const loadOrchestrationCapableInstanceIds = () => providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds))); - /** - * Provider snapshots only re-probe while a client is in the foreground, so an - * unattended agent can see a provider as unavailable after it was fixed. - * Re-probe the requested instance once before refusing it. - */ + /** Background agents miss foreground-only health probes; re-probe once before refusing. */ const assertTargetEnabledForProject = ( parent: Pick, commandId: CommandId, @@ -1507,13 +1491,7 @@ const make = Effect.gen(function* () { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; readonly providers: ReadonlyArray; - /** - * Set only when the command that will dispatch this target already has a receipt: that - * dispatch can only replay the recorded result, never start anything new, so filtering - * candidates by the project's current settings would just refuse a retry that does no new - * work. Never set this for a resolution whose dispatch uses a different command id than the - * one checked for a receipt. - */ + /** Receipt replays bypass current settings because they start no work. Dispatch must reuse the checked command ID. */ readonly skipProjectEnablementFilter?: boolean; }): Effect.fn.Return { const requestedInstanceId = input.target?.providerInstanceId; @@ -1796,9 +1774,10 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; - const response = { + const result = { taskId: task.id, childThreadId: task.childThreadId, + link: formatThreadLink(task.childThreadId, childControls.thread.title), childRunId: childRun?.id ?? null, childNodeId: task.id, status, @@ -1831,6 +1810,10 @@ const make = Effect.gen(function* () { : pendingTaskApproval(childControls, task.id, childRun), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; + const response: OrchestratorMcpDelegateTaskResult = + endsTaskWait(result) || task.completionDelivery?.state === "disposed" + ? result + : { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT }; if ( acknowledgeTerminal && isTerminalTaskStatus(status) && @@ -1873,8 +1856,7 @@ const make = Effect.gen(function* () { "orchestration_error", `Unable to watch delegated task ${taskId}: ${errorMessage(error)}`, ); - // Sequences are global, so one cursor taken before the first read - // replays anything either thread records after it. + // One global cursor before both reads captures later events from either thread. const afterSequence = yield* threadManagement .getThreadEventSequence(scope.thread.threadId) .pipe(Effect.mapError(streamError)); @@ -2082,9 +2064,7 @@ const make = Effect.gen(function* () { : input.bindToCurrentThread && parent !== undefined ? parent.thread.id : null; - // Rebinding changes where runs execute, so the workspace strategy must - // follow: unbinding a root-strategy task would otherwise run loose - // prompts in the shared project checkout. + // Update workspace strategy on rebinding so unbound root tasks cannot run in the shared checkout. const workspaceStrategy = input.bindToCurrentThread === undefined ? existing.workspaceStrategy @@ -2165,6 +2145,7 @@ const make = Effect.gen(function* () { label: input.label, reason: input.reason, ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }), + ...(input.delivery === "file" ? { delivery: "file" as const } : {}), secretStatus, }) .pipe( @@ -2243,6 +2224,12 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "secret_request.status": status }); yield* Metrics.increment(Metrics.secretRequestsTotal, { status }); if (status !== "saved") return { status }; + if (input.delivery === "file") { + const file = yield* secretRequests + .saveToFile({ threadId, turnItemId }) + .pipe(Effect.mapError((error) => failure("orchestration_error", error.message))); + return { status, file }; + } const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId }); if (Option.isNone(secretRef)) { return yield* failure( @@ -2661,7 +2648,7 @@ const make = Effect.gen(function* () { ), ), ); - // A follow-up that lost its child run (#19002) stays running on the parent, while its + // A follow-up that lost its child run stays running on the parent, while its // status here comes from the child's last run. Stop on the parent cancels that task. const cancelOrphan = Effect.gen(function* () { if (parentTask?.runId == null || !isOrchestrationV2WorkActive(parentTask.status)) @@ -2881,6 +2868,7 @@ const make = Effect.gen(function* () { ); return { threadId, + link: formatThreadLink(threadId, projection.thread.title), runId: run?.id ?? null, status: run?.status ?? "idle", title: projection.thread.title, @@ -3098,6 +3086,7 @@ const make = Effect.gen(function* () { ); return { threadId: input.threadId, + link: formatThreadLink(input.threadId, target.thread.title), messageId, runId: result.run.id, ...(result.run.delegatedTaskId === undefined @@ -3110,7 +3099,7 @@ const make = Effect.gen(function* () { ), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { yield* assertNotSelf(scope.thread?.threadId, input.threadId); - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -3129,12 +3118,34 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(threadManagementFailure)) : null; const usageLimit = shell === null ? null : threadUsageLimit(shell); + const status = result.run === null ? "idle" : runOutcome(result.run); + const taskId = result.run?.delegatedTaskId; + const ownedTask = + result.timedOut && + status !== "waiting" && + parent?.subagents.some( + (task) => + task.id === taskId && + task.origin === "app_owned" && + task.threadId === parent.thread.id && + task.childThreadId === input.threadId && + task.completionDelivery?.state !== "disposed", + ) === true; + // A child waiting on a question or approval needs an answer, not a wake. + const childRequests = ownedTask + ? (yield* threadManagement + .getThreadRecords(input.threadId, ["runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure))).runtimeRequests + : []; + const wakesCaller = + ownedTask && !childRequests.some((request) => request.status === "pending"); return { threadId: input.threadId, runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), + status, timedOut: result.timedOut, usageLimit: usageLimit?.runId === result.run?.id ? usageLimit : null, + ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 6f55540ddcb6b..8c26360e61443 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1599,7 +1599,6 @@ it.effect("keeps each thread's current tab when threads share a provider session ["thread-1", "tab-thread-1"], ["thread-2", "tab-thread-2"], ]); - // Each thread's agent owns its own tabs and tab budget. expect(routed[0]!.agentSessionId).not.toBe(routed[1]!.agentSessionId); }), ), diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 681954cfa119f..a2dc4ac0797cc 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -112,11 +112,7 @@ interface PendingRequest { readonly context: PreviewAutomationRequestErrorContext; } -/** - * It lives exactly as long as the connection it names: `connectionId`/`queue` - * identity is what makes a lease valid, so a disconnected or replaced host is - * dropped on the next lookup. - */ +/** A lease belongs to this exact connectionId/queue; replacing the host invalidates it. */ interface HostAssignment { readonly clientId: ClientConnection["clientId"]; readonly connectionId: ClientConnection["connectionId"]; @@ -185,11 +181,7 @@ const selectorDiagnosticsFromInput = ( return {}; }; -/** - * One agent: a provider session on one thread. Codex and OpenCode run every - * thread of a provider instance in one session, so the session alone would - * share one current tab, tab budget, and tab ownership across chats. - */ +/** Codex/OpenCode share sessions across threads; include thread id to isolate tabs and ownership. */ const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string => `${scope.environmentId}\u0000${scope.thread.providerSessionId}\u0000${scope.thread.threadId}`; @@ -224,6 +216,14 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } +const readRemoteTimeoutMs = (detail: unknown): number | undefined => { + if (typeof detail !== "object" || detail === null || !("timeoutMs" in detail)) return undefined; + const timeoutMs = detail.timeoutMs; + return typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : undefined; +}; + const MAX_REASON_CHARS = 500; const classifyResponseError = ( @@ -272,11 +272,16 @@ const classifyResponseError = ( ...context, ...remoteDiagnostics, }); - case "PreviewAutomationTimeoutError": + case "PreviewAutomationTimeoutError": { + const remoteTimeoutMs = readRemoteTimeoutMs(error.detail); return new PreviewAutomationTimeoutError({ ...context, ...remoteDiagnostics, + ...(remoteTimeoutMs === undefined + ? {} + : { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }), }); + } case "PreviewAutomationControlInterruptedError": { const reason = decodeControlReason(error.detail); return new PreviewAutomationControlInterruptedError({ @@ -376,8 +381,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { completeStream = false, ) { if (completeStream) { - // Discard this generation's commands and complete the RPC stream so a - // responsive desktop can re-register after a timeout eviction. + // Close the evicted generation's RPC stream so a responsive desktop can re-register. yield* Queue.clear(queue); yield* Queue.end(queue); } else { @@ -574,9 +578,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const assigned = assignments.get(assignmentKey); const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; - // Keep one provider session on one physical desktop runtime so a - // multi-step browser interaction cannot jump between independent Electron - // cookie/DOM state. + // Pin interactions to one desktop runtime so cookie/DOM state cannot jump between hosts. const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => host.liveTabs.some( (tab) => @@ -609,8 +611,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { if (!hasLiveAssignment) assignments.delete(assignmentKey); return [undefined, { ...current, assignments }] as const; } - // The environment host may install Chromium on its first open (up to - // ten minutes). Keep that request alive without replaying its effects. + // First open may install Chromium for ten minutes; keep it alive without replaying effects. const timeoutMs = input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000); const canReuseAssignedTab = @@ -729,11 +730,9 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { return yield* Option.match(result, { onNone: () => Effect.gen(function* () { - // Remote silence can mean the connection is gone. An in-process - // host stays registered so a slow operation cannot abort other work. + // Remote silence can mean disconnection; slow in-process hosts stay registered. // Never replay actions whose effects may already have been applied. - // A background metadata read has a short budget and changes nothing, - // so a slow one must not cut the host off from the agent's next call. + // Read-only background metadata timeouts must not disconnect the host. if (connection.disconnectOnTimeout && input.updateCurrentTab !== false) { yield* disconnect(connection.clientId, connection.queue, true); } diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 786738bed3c52..8ad03a18ee12d 100644 --- a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts @@ -50,9 +50,6 @@ const driver = ProviderDriverKind.make("claudeAgent"); const instanceId = ProviderInstanceId.make("claudeAgent"); const modelSelection = { instanceId, model: "claude-test" }; -// A delegated Claude child ends its turn while a background native subagent it -// started keeps running (#17154). - it.effect("task_cancel ends a delegated child's background native subagent", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/ThreadMetadataMcpService.ts b/apps/server/src/mcp/ThreadMetadataMcpService.ts index e05200fd8eda0..421d728461eb1 100644 --- a/apps/server/src/mcp/ThreadMetadataMcpService.ts +++ b/apps/server/src/mcp/ThreadMetadataMcpService.ts @@ -203,8 +203,7 @@ const make = Effect.gen(function* () { const metadataEvent = dispatched.storedEvents.find( (stored) => stored.event.type === "thread.metadata-updated", ); - // A retried commandId replays the stored events of the first dispatch; - // compaction may have deleted them by then, so answer from the projection. + // Retries may find stored events compacted away; answer from the projection. const thread = metadataEvent?.event.type === "thread.metadata-updated" ? metadataEvent.event.payload diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index bab22c76af266..8792400fc92a3 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -348,9 +348,7 @@ const expectTypedFailure = (exit: Exit.Exit, expected: object) expect(reason.error).toMatchObject(expected); }; -// Resolves the service once from the harness layer; used by tests that must -// make several calls against the SAME instance (the in-flight guard lives in -// the layer's closure, so a fresh layer per call would never see it). +// Resolve once so calls share the in-flight guard. const resolveService = (harness: ReturnType) => Effect.gen(function* () { return yield* WorktreeMcpService.WorktreeMcpService; @@ -371,7 +369,6 @@ const runStatus = (harness: ReturnType) => return yield* service.status(harness.scope); }).pipe(Effect.provide(harness.layer)); -// Decodes tool arguments the way the MCP tool does before calling the service. const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput); describe("t3_worktree_handoff", () => { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index fce015173329d..81c7ab1f4bbce 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -65,9 +65,7 @@ const make = Effect.gen(function* () { const setupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; - // Serializes handoffs per thread: two concurrent calls could otherwise both - // pass the worktreePath === null check and each create a worktree, leaving - // one untracked on disk. + // Serialize handoffs per thread to avoid creating two worktrees and orphaning one. const handoffThreadsInFlight = new Set(); const requireThreadScope = (scope: McpInvocationScope) => @@ -199,12 +197,9 @@ const make = Effect.gen(function* () { const samePath = (left: string, right: string) => path.resolve(left) === path.resolve(right); - // An existing branch is either the checkout the caller named with `path`, - // which the thread moves into, or a mistake. The git driver deliberately - // keeps stderr out of its errors, so letting `git worktree add` fail would - // surface only an opaque failure. The existence check uses the complete - // local branch list (exact match); the paginated substring search only - // finds the checkout location when available. + // Reject existing branches explicitly: the git driver's errors omit stderr. + // Check the complete local branch list for an exact match; substring search + // only locates a checkout the caller can select with `path`. let existingWorktreePath: string | null = null; const localBranchNames = yield* gitWorkflow .listLocalBranchNames(projectCwd) @@ -257,14 +252,7 @@ const make = Effect.gen(function* () { const ids = yield* handoffIds(scope); - // uninterruptibleMask: only the potentially slow worktree creation itself - // stays interruptible (restore). From the moment it succeeds, through the - // binding, continuation queue, setup script launch, and result - // construction, there is no interruptible gap: a client cancel can - // therefore neither orphan the created worktree before the rollback is - // armed, nor skip setting up a worktree the thread was just bound to - // (once the binding commits, the scheduled session detach can sever this - // request's connection and interrupt the fiber). + // Mask interruption after worktree creation through setup; binding can detach and interrupt the caller. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const { created } = target; @@ -310,10 +298,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.ignoreCause({ log: true })); const recheckAndBind = Effect.gen(function* () { - // The projection was read before the potentially slow git work - // above; a concurrent binding (for example from the UI) could have - // moved the thread in the meantime. Re-check before committing so - // the race cannot leave a second, untracked worktree. + // A concurrent UI binding may have landed during git work; do not orphan a second worktree. const recheck = yield* loadThread(scope); if (recheck.thread.worktreePath !== previousWorktreePath) { return yield* failure( @@ -349,22 +334,13 @@ const make = Effect.gen(function* () { ), ); }).pipe( - // onError: a worktree this handoff created must be removed again - // on any failure between here and the committed binding (recheck - // read, recheck race, dispatch typed failure or defect) so a failed - // handoff leaves nothing behind on disk. An existing worktree is never - // removed. Interrupt-only causes skip the removal: the binding may - // have committed, and force-deleting a worktree the thread now points - // at would be worse than leaking one. + // Do not remove on interruption alone; the thread binding may already have committed. Effect.onError((cause) => !created || Cause.hasInterruptsOnly(cause) ? Effect.void : removeCreatedWorktree, ), ); - // Queue the continuation right after the binding commits: the detach - // that the metadata update schedules will terminate the calling - // session, and a durably queued message is what guarantees the thread - // re-launches inside the worktree. + // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message. const queueContinuation: Effect.Effect = Effect.suspend(() => input.continuationPrompt === undefined @@ -449,11 +425,7 @@ const make = Effect.gen(function* () { function* (callerScope, input) { yield* requireCapability(callerScope); const scope = yield* requireThreadScope(callerScope); - // uninterruptibleMask: the guard acquisition and the registration of the - // releasing finalizer happen with no interruptible gap in between. An - // interrupt landing between a bare add() and the start of an ensured - // effect would otherwise leak the guard entry and block every future - // handoff for this thread until restart. + // Acquire and register cleanup atomically so cancellation cannot strand the guard until restart. return yield* Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (handoffThreadsInFlight.has(scope.thread.threadId)) { diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index ba8dc4c3e5765..9f3a8a57c05b2 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -102,12 +102,7 @@ export const assertTargetWithinLimits = ( Effect.asVoid, ); -/** - * A thread caller acts only while it owns a live run, or a background - * subagent that run started, of a thread that is not archived, so a provider - * token that outlived its session cannot act. An OAuth client has no run; its - * session and ceiling are its authority. - */ +/** Thread callers need a live run or its background subagent in an unarchived thread. OAuth clients rely on their session and ceiling. */ export function assertLiveCaller({ caller, scope }: Caller) { if (caller === undefined) return Effect.void; return OrchestrationMcp.isLiveCallerShell(caller, scope.thread?.providerInstanceId) diff --git a/apps/server/src/mcp/toolkits/attachment/handlers.ts b/apps/server/src/mcp/toolkits/attachment/handlers.ts index e1a6428eba8d3..4c7f168f60128 100644 --- a/apps/server/src/mcp/toolkits/attachment/handlers.ts +++ b/apps/server/src/mcp/toolkits/attachment/handlers.ts @@ -34,10 +34,7 @@ export function resolveAttachmentReferences( }); } -/** - * The sweep counts its 24 hours from when the upload finishes, which can be up - * to the upload URL's lifetime after issue. - */ +/** The 24-hour sweep starts at upload completion, possibly one upload-URL lifetime after issue. */ const UPLOAD_OWNER_TTL_MS = 24 * 60 * 60 * 1000 + ATTACHMENT_UPLOAD_URL_TTL_MS; const uploadOwner = McpInvocationContext.McpInvocationContext.pipe( diff --git a/apps/server/src/mcp/toolkits/attachment/input.ts b/apps/server/src/mcp/toolkits/attachment/input.ts index f29d00d1f9f9d..1f37edbbf6d4d 100644 --- a/apps/server/src/mcp/toolkits/attachment/input.ts +++ b/apps/server/src/mcp/toolkits/attachment/input.ts @@ -2,8 +2,7 @@ import { ChatFileAttachment, ChatImageAttachment } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -// Screenshot provenance is app-owned. Existing attachments recover it from stored -// metadata; exposing its recursive accessibility tree breaks model tool schemas. +// Keep screenshot provenance in app metadata; its recursive tree breaks model tool schemas. export const McpAttachmentInput = Schema.Union([ ChatImageAttachment.mapFields(Struct.omit(["source"])), ChatFileAttachment, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index c5211be071d14..744c147517f54 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -83,8 +83,6 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(NodeServices.layer), ); -// Effect returns a declared tool failure as `isError` with its encoded payload -// as JSON text, never as `structuredContent`. const declaredFailure = (result: McpSchema.CallToolResult) => { const text = result.content[0]; return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 31d8c41dccf4d..36a91b844ade5 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -30,8 +30,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", - // An empty struct serializes as `anyOf [object, array]`, which some - // providers reject and then drop every tool on the server with it. + // Empty Struct emits object|array, which can make providers reject every server tool. parameters: Schema.Struct({ hostId: Schema.optional( Schema.String.annotate({ description: "Limit to one device host. Defaults to all hosts." }), diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index 4ad552b815f64..c54a4ecbd9d12 100644 --- a/apps/server/src/mcp/toolkits/html/handlers.ts +++ b/apps/server/src/mcp/toolkits/html/handlers.ts @@ -20,8 +20,7 @@ const toFailure = (error: { readonly _tag: string; readonly message: string }) = }); const handlers = { - // The headless browser runs on the host and can open local files, so only - // agents T3 launched, which already work on this machine, get it. + // Local-file access limits this host browser to T3-launched agents. html_preview: McpToolAccess.readsAsCaller((input) => Effect.gen(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/mcp/toolkits/html/tools.ts b/apps/server/src/mcp/toolkits/html/tools.ts index 17b1600fc2a8c..a18f2d99f0ce4 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -69,8 +69,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, true); -// Read-only in the MCP sense: it shows a page in the caller's own thread and -// touches no workspace, so plan mode and read-only sandboxes can use it. +// This affects only the caller's thread, not its workspace, so read-only sandboxes and plan mode may use it. const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width. On mobile, a height below the page's contentHeight caps the frame there, and the rest scrolls inside it; web and desktop fit the whole page, up to ${HTML_RENDER_MAX_FIT_HEIGHT}px. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, parameters: Schema.Struct({ diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 9ee887dd8a7f6..1b3235cbff720 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -58,6 +58,27 @@ const handlers = { ); }), ), + t3_background_wait: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const { scope, threads } = yield* readCaller(); + const caller = yield* McpInvocationContext.requireThreadScope(scope, "t3_background_wait"); + return yield* threads + .awaitBackgroundTasks({ + threadId: caller.thread.threadId, + taskIds: input.taskIds, + awaited: input.waiting ?? true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: cause.reason === "unsupported" ? "invalid_request" : "orchestration_error", + message: cause.message, + }), + ), + ); + }), + ), schedule_task: McpToolAccess.startsThreads( () => ({}), (input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index a358c8e643921..fa9ced1b69a9c 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -35,6 +35,7 @@ import { OrchestratorMcpThreadWaitResult, ThreadMetadataMcpUpdateInput, ThreadMetadataMcpUpdateResult, + TrimmedNonEmptyString, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; @@ -121,6 +122,25 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { .annotate(Tool.Title, "Stop a workflow") .annotate(Tool.Destructive, true); +const BackgroundWaitTool = Tool.make("t3_background_wait", { + description: + "Keep this thread in the user's Working list and out of their inbox while you wait for background work. Start a background command or Monitor first. Call this before you end your turn if you will act on its result when it wakes you. In taskIds, give the ID shown when the task started. The thread stays in Working until the task ends. The user is not told the work is done. Do not mark work you leave running, such as a dev server or watcher. Unmarked commands and monitors never keep the thread in Working. Pass waiting=false to remove the mark. This does not stop the task. IDs that are not running return in unmatched and change nothing. Only Claude threads support this tool. Other providers return an error.", + parameters: Schema.Struct({ + taskIds: Schema.Array(TrimmedNonEmptyString).check( + Schema.isMinLength(1), + Schema.isMaxLength(20), + ), + waiting: Schema.optional(Schema.Boolean), + }), + success: Schema.Struct({ + matched: Schema.Array(Schema.String), + unmatched: Schema.Array(Schema.String), + }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}).annotate(Tool.Title, "Wait on background work"); + const ScheduleTaskTool = Tool.make("schedule_task", { description: "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", @@ -174,7 +194,7 @@ const DeleteScheduledTaskTool = Tool.make("delete_scheduled_task", { const RequestSecretTool = Tool.make("request_secret", { description: - "Ask the user for a secret (a token, API key, signing secret, password) through a private card in this thread, and wait for them to answer. The value is kept by the app and NEVER returned to you or shown in the transcript. When saved, the result carries a secretRef: pass it to a tool that accepts one (e.g. schedule_task's signature.secretRef). It works once. Never ask for secrets in chat, and never invent one.", + "Ask the user for a secret (a token, API key, signing secret, password) through a private card in this thread, and wait for them to answer. The value is NEVER returned to you or shown in the transcript. By default the result carries a one-use secretRef for a tool that accepts one (e.g. schedule_task's signature.secretRef). For a shell CLI token, set delivery to file. Never ask for secrets in chat, and never invent one.", parameters: OrchestratorMcpRequestSecretInput, success: OrchestratorMcpRequestSecretResult, failure: OrchestratorMcpFailure, @@ -199,7 +219,7 @@ const CreateThreadsTool = Tool.make("create_threads", { const ThreadListTool = Tool.make("t3_thread_list", { description: - "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "List T3 threads in a project, newest first. Omit projectId for the calling thread's project. Filter by durable run status, title, or settled state (settled=true lists threads the user or auto-settlement moved out of the active list), or snoozed state, and paginate with the returned cursor. A snoozed thread wakes early only when it asks for something or fails after snoozing; completed runs and background wakes keep it snoozed. pullRequests lists every pull request linked to each thread; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadListInput, success: OrchestratorMcpThreadListResult, failure: OrchestratorMcpFailure, @@ -213,7 +233,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. To link a thread for the user, write `[title](t3-thread://v1/)` with the threadId exactly as returned, not URL-encoded; T3 Code shows the thread's current title.", + "Read durable state and a paginated timeline from any T3 thread in this environment. The default messages view returns user messages, assistant messages, and proposed plans; activity returns all summarized timeline items. Reading an untruncated terminal assistant result from this parent thread's direct app-owned child acknowledges that child's automatic completion delivery. Continue with afterPosition=nextPosition. Recover long item text with itemId and textOffset=nextTextOffset until nextTextOffset is null; offsets count UTF-16 code units. The thread also reports its snooze state and, in pullRequests, every linked pull request; linkedPullRequest is a legacy single link and can be null while pullRequests still has entries. Mention a thread with its `link`.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -309,6 +329,7 @@ export const OrchestratorToolkit = Toolkit.make( TaskStatusTool, TaskCancelTool, WorkflowStopTool, + BackgroundWaitTool, ScheduleTaskTool, ListScheduledTasksTool, UpdateScheduledTaskTool, diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index bbcab764af8f7..95e3d0cf82e6f 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -252,11 +252,7 @@ const PreviewScrollTool = safeBrowserTool( }).annotate(Tool.Title, "Scroll preview page"), ); -/** - * MCP `structuredContent` must be a JSON object, and Claude Code rejects the - * whole result when it is not. Wrapping keeps arrays, strings, numbers, and - * null valid instead of failing only for non-object expressions. - */ +/** Wrap non-objects: MCP requires object structuredContent, and Claude Code rejects invalid results entirely. */ export const PreviewEvaluateResult = Schema.Struct({ ...presentationFields, value: Schema.Unknown.annotate({ diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index f6643c74046e1..0fe1733a020a9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -394,6 +394,7 @@ const clientLaunchHarness = (input: { thread: { id: launch.threadId, projectId: launch.projectId, + title: launch.title, modelSelection: launch.modelSelection, }, runs: [], @@ -472,6 +473,7 @@ it.effect( expect(belowCeiling.result).toMatchObject({ projectId: belowCeiling.projectId, modelSelection: belowCeiling.modelSelection, + link: `[Fix](t3-thread://v1/${belowCeiling.launched[0]?.threadId})`, }); expect(belowCeiling.launched[0]?.runtimeMode).toBe("approval-required"); expect(belowCeiling.launched[0]?.initialMessage?.senderThreadId).toBeUndefined(); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 05700357ff3a2..36b2c0989f297 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -12,6 +12,7 @@ import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as McpContextLinks from "../../McpContextLinks.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { resolveRuntimeMode } from "../../OrchestratorMcpService.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -122,9 +123,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } - // A launch is accepted before its worktree is provisioned, so a - // worktree that cannot be created is refused while the agent can - // still act on it. + // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; if (strategy?.type === "worktree") yield* ThreadLaunch.ThreadLaunchService.pipe( @@ -232,6 +231,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { const run = result.projection.runs.find((run) => run.userMessageId === messageId); return { threadId: thread.id, + link: formatThreadLink(thread.id, thread.title), projectId: thread.projectId, modelSelection: thread.modelSelection, runId: run?.id ?? null, @@ -265,8 +265,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.gen(function* () { const projects = yield* Project.ProjectService; if (workspaceRoot === undefined) { - // Project creation records no model default (only an update does), so - // reject what this mode would otherwise drop silently. + // Only updates record model defaults; reject defaults that creation would silently drop. if ( input.scripts !== undefined || input.createWorkspaceRootIfMissing !== undefined || diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index f370836d1566f..08f089b1a2810 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -136,6 +136,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", { }), success: Schema.Struct({ threadId: ThreadId, + link: Schema.optional(Schema.String), projectId: ProjectId, modelSelection: ModelSelection, runId: Schema.NullOr(RunId), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 25c0142230c69..507381531ac49 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -353,6 +353,39 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("canonicalizes GitCafe links independently of the thread's project host", () => + Effect.gen(function* () { + for (const host of ["git.cafe", "staging.git.cafe"]) { + for (const project of [makeProject(), makeProject(null)]) { + for (const target of [ + { host, repository: "PingDotGG/T3Code", number: 21353 }, + { url: `https://${host}/PingDotGG/T3Code/pull/21353` }, + { url: `https://${host}/PingDotGG/T3Code/pulls/21353` }, + ]) { + const harness = yield* makeHarness({ project }); + const result = yield* harness.call("link_pull_request", target); + const url = `https://${host}/pingdotgg/t3code/pulls/21353`; + expect(result).toMatchObject({ + host, + repository: "pingdotgg/t3code", + number: 21353, + url, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.link", + host, + repository: "pingdotgg/t3code", + number: 21353, + url, + }, + ]); + } + } + } + }), + ); + it.effect("builds the URL in the project host's own shape", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 5079083ca840d..4aba7295ab78d 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -6,6 +6,7 @@ import { type ThreadId, type ThreadPullRequestLink, } from "@t3tools/contracts"; +import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -76,7 +77,10 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( if (parsed === undefined) { return yield* new PullRequestUrlInvalidError({}); } - return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; + return { + ...normalizeThreadPullRequestKey(parsed), + url: canonicalizeGitCafePullRequestUrl(input.url), + } satisfies ResolvedTarget; } if (input.repository === undefined || input.number === undefined) { return yield* new PullRequestTargetIncompleteError({}); @@ -89,7 +93,10 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( const repository = input.repository.toLowerCase(); // Without a URL, a public forge host says what shape its change request URLs take; // any other host is taken to run the project's own forge. - const kind = sourceControlDefinitions.findByPublicHost(host)?.kind ?? projectHost.kind; + const recognizedKind = sourceControlDefinitions.recognizeRemote(`https://${host}`)?.kind; + const kind = + sourceControlDefinitions.findByPublicHost(host)?.kind ?? + (recognizedKind === "gitcafe" ? recognizedKind : projectHost.kind); const url = kind === null ? null @@ -104,7 +111,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( } return { ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), - url, + url: canonicalizeGitCafePullRequestUrl(url), } satisfies ResolvedTarget; }); @@ -159,8 +166,7 @@ function projectForPullRequest( target: { readonly host: string; readonly repository: string }, preferred: OrchestrationProjectShell | undefined, ): OrchestrationProjectShell | undefined { - // The same rule the guide service uses: a pull request reads through a project whose checkout - // names it with the same host-level key, which also maps Azure DevOps' org/project/_git/repo. + // Match the guide service's host-level repository key, including Azure DevOps org/project/_git/repo. const matches = (project: OrchestrationProjectShell) => { const own = pullRequestSyncKey( { projectId: project.id, host: target.host, repository: target.repository, number: 1 }, diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 21a4424da60a6..c22830ecb5ff3 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -10,6 +10,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as McpSchema from "effect/ai/McpSchema"; import * as Tool from "effect/ai/Tool"; import * as Toolkit from "effect/ai/Toolkit"; @@ -264,7 +265,7 @@ export const ThreadPullRequestEntry = Schema.Struct({ stack: Schema.NullOr( Schema.Struct({ kind: Schema.Literals(["native", "derived"]), - /** 1-based layer, bottom of the stack first. Pull requests built on the same one share it. */ + /** 1-based layer from the bottom; siblings share a layer. */ position: Schema.Int, size: Schema.Int, }), @@ -340,7 +341,8 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { description: @@ -354,7 +356,8 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true) - .annotate(Tool.OpenWorld, false); + .annotate(Tool.OpenWorld, false) + .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenFromSubagents); const GuideTarget = Schema.Struct({ url: PullRequestTargetInput.fields.url, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 268b5d6a0dfbd..86e08b6d6c0ef 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -12,6 +12,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -308,9 +309,15 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { Effect.gen(function* () { const { caller } = yield* readCaller(); const threadSearch = yield* ThreadSearch.ThreadSearch; - return yield* threadSearch + const { matches } = yield* threadSearch .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) .pipe(Effect.mapError(unavailable)); + return { + matches: matches.map((match) => ({ + ...match, + link: formatThreadLink(match.threadId, match.title), + })), + }; }), ), t3_thread_fork: writesThread((input) => diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 8e5dd27ace3fe..e27a124ed7b72 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -306,6 +306,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { threadId: OrchestrationThreadSearchMatch.fields.threadId, projectId: OrchestrationThreadSearchMatch.fields.projectId, title: Schema.String, + link: Schema.optional(Schema.String), source: Schema.Literals(["title", "user", "assistant", "group_name"]), snippet: Schema.optional(OrchestrationThreadSearchMatch.fields.snippet), messageCreatedAt: Schema.optional(OrchestrationThreadSearchMatch.fields.messageCreatedAt), diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts index d50236ce6cd1b..6e563e10b56ff 100644 --- a/apps/server/src/mcpApps/McpAppModelContext.ts +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -24,7 +24,7 @@ export interface McpAppModelContextEntry { export class McpAppModelContext extends Context.Service< McpAppModelContext, { - /** Replaces the app's context; empty text removes it. */ + /** Empty text removes the app's context. */ readonly set: (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -67,9 +67,7 @@ export const layer = Layer.effect( }); const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { - // The item can live in a thread this one was forked from, so it is - // matched by id alone; that thread rolling the run back later leaves the - // fork's copy in place. + // Match by id across fork lineage; later source rollbacks leave the fork's copy intact. return yield* sql` SELECT context.item_id AS "itemId", context.server, context.tool, context.text FROM mcp_app_model_context AS context diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 6e97a726a021b..1387cc6b20c7a 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -26,10 +26,7 @@ import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionMana import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; -/** - * A stopped session is reported, never started: an app view must not spend a - * provider turn. - */ +/** Reports stopped sessions without starting them; app views must not spend provider turns. */ export class McpAppRequests extends Context.Service< McpAppRequests, { @@ -133,8 +130,7 @@ const make = Effect.gen(function* () { const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { const resolved = yield* resolve(input); - // The spec forbids apps calling tools hidden from them; checked here too, - // since a client's own check is only a courtesy to its user. + // Enforce hidden-tool restrictions server-side; client checks cannot enforce the spec. const tool = yield* findTool(input.threadId, resolved, input.name); if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { return yield* fail(input.threadId, "tool-not-callable"); @@ -184,8 +180,7 @@ const make = Effect.gen(function* () { input: McpAppUpdateModelContextInput, ) { const { app } = yield* resolveApp(input); - // Context belongs to the conversation on screen, which only shows this app - // when it is the app's own thread or one forked from it. + // Context belongs only to the app's thread or its descendants. if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { return yield* fail(input.threadId, "not-an-app"); } diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts index f47237f5c82c3..a43c01a404178 100644 --- a/apps/server/src/mcpApps/McpAppSnapshot.ts +++ b/apps/server/src/mcpApps/McpAppSnapshot.ts @@ -26,9 +26,7 @@ function readMcpAppDocument( if (content.uri !== undefined && content.uri !== uri) continue; const mimeType = typeof content.mimeType === "string" ? content.mimeType : ""; if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue; - // Sizes are checked before decoding, so an oversized resource is - // refused without first being copied: a string's UTF-8 form is at least - // its length, and base64 decodes to three quarters of its own. + // Bound before decoding to avoid oversized copies; UTF-8 is at least string length, base64 decodes to 3/4. const html = typeof content.text === "string" ? content.text.length > MCP_APP_MAX_HTML_BYTES @@ -79,8 +77,7 @@ export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (in const ui = Predicate.isObject(document.meta) ? document.meta : {}; const csp = readMcpAppCsp(ui.csp); const fileSystem = yield* FileSystem.FileSystem; - // Only the returned reference lets thread deletion find the document, so a - // write that fails or is interrupted removes it. + // Failed or interrupted writes remove documents that thread deletion cannot yet find. yield* fileSystem .writeFileString(filePath, injectMcpAppCsp(html, csp)) .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore))); diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index ee197dd0d7d34..1802a4a665917 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -10,7 +10,7 @@ export class BrowserTraceCollector extends Context.Service< } >()("t3/observability/BrowserTraceCollector") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => BrowserTraceCollector.of({ record: (records) => diff --git a/apps/server/src/observability/DefectReporter.ts b/apps/server/src/observability/DefectReporter.ts index 4e8911c1493de..39e93ba5fb44a 100644 --- a/apps/server/src/observability/DefectReporter.ts +++ b/apps/server/src/observability/DefectReporter.ts @@ -2,10 +2,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as ErrorReporter from "effect/ErrorReporter"; -/** - * RpcServer reports every failed handler exit; typed failures are expected responses, so only - * dies are logged. - */ +// RpcServer reports typed failures too; log only defects. const reporter: ErrorReporter.ErrorReporter = { [ErrorReporter.TypeId]: ErrorReporter.TypeId, report: ({ cause, fiber }) => { diff --git a/apps/server/src/observability/EventLoopMonitor.test.ts b/apps/server/src/observability/EventLoopMonitor.test.ts index 767e10bce8fb6..a3a58e60e4e26 100644 --- a/apps/server/src/observability/EventLoopMonitor.test.ts +++ b/apps/server/src/observability/EventLoopMonitor.test.ts @@ -25,7 +25,6 @@ const stalled: EventLoopMonitor.EventLoopReadings = { describe("EventLoopMonitor", () => { 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: EventLoopMonitor.EventLoopReadings = { ...stalled, delayMaxNs: ms(600_000), diff --git a/apps/server/src/observability/EventLoopMonitor.ts b/apps/server/src/observability/EventLoopMonitor.ts index 5e4d55ff59bd0..ed3befd0bb678 100644 --- a/apps/server/src/observability/EventLoopMonitor.ts +++ b/apps/server/src/observability/EventLoopMonitor.ts @@ -18,10 +18,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as Metrics from "./Metrics.ts"; -// 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. +// Subtract the histogram's timer resolution; this can undercount stalls by up to RESOLUTION_MS. const RESOLUTION_MS = 1000; const STALL_THRESHOLD_MS = 2000; const SAMPLE_INTERVAL = "30 seconds"; @@ -113,8 +110,7 @@ const makeNodeSampler = Effect.gen(function* () { export const stallMs = ({ delayMaxNs, activeMs }: EventLoopReadings) => { const delayMs = Math.round(delayMaxNs / 1e6) - RESOLUTION_MS; - // 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. + // libuv clocks run during macOS/Windows sleep; idle poll time distinguishes it from stalls. if (delayMs <= STALL_THRESHOLD_MS || activeMs < delayMs) return undefined; return delayMs; }; @@ -216,10 +212,7 @@ const layerWith = ( } }).pipe(sampling.withPermits(1)); 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. + // The first sample includes migrations and bootstrap; skip expected startup stalls. yield* wait.pipe( Effect.andThen(sampling.withPermits(1)(sample)), Effect.andThen(Ref.set(warmedUp, true)), diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index 9d3b3e13c0fcb..be92b47a5bdee 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -171,8 +171,7 @@ const recordMetrics = } }); -// Durations come from the monotonic clock, so wall-clock corrections cannot skew them, and -// metrics are recorded in an exit finalizer, so interrupted work is counted as "interrupt". +// Monotonic timing ignores clock corrections; exit finalizers count interruptions. const withMetricsImpl = ( effect: Effect.Effect, options: WithMetricsOptions, diff --git a/apps/server/src/observability/Observability.ts b/apps/server/src/observability/Observability.ts index 05c382ea38281..343851e3d1d11 100644 --- a/apps/server/src/observability/Observability.ts +++ b/apps/server/src/observability/Observability.ts @@ -23,8 +23,7 @@ export const layer = Layer.unwrap( const traces = config.otlpTracesExport; const metrics = config.otlpMetricsExport; - // The trace serializer stays in the returned context because the browser - // trace forwarder exports on the same signal. + // Keep serialization in context for the browser trace forwarder's exporter. const layerSerialization = SharedObservability.layerOtlpSerialization(traces.protocol); const resource = ServerConfig.otlpResource(config, yield* HostProcess.Hostname); const attribution = yield* ResourceAttribution.ResourceAttribution; @@ -32,8 +31,7 @@ export const layer = Layer.unwrap( const layerTraceReferences = Layer.mergeAll( Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel), Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), - // Keeps each SQL statement's span current while it waits for a connection, - // so the client can record the lock wait on it. + // Preserve SQL spans while awaiting connections so lock waits can be recorded on them. Layer.succeed(Statement.SpanPropagationEnabled, true), HttpObservability.layer, ); diff --git a/apps/server/src/observability/OtlpEndpointCheck.ts b/apps/server/src/observability/OtlpEndpointCheck.ts index 6a136a2ff7aba..cb764f4557d92 100644 --- a/apps/server/src/observability/OtlpEndpointCheck.ts +++ b/apps/server/src/observability/OtlpEndpointCheck.ts @@ -58,9 +58,7 @@ const make = Effect.gen(function* () { return emptyExport(serialization, signal); }).pipe(Effect.provide(layerOtlpSerialization(signalExport.protocol))); const startedAt = yield* Clock.currentTimeMillis; - // Only the status matters. The scope aborts the request once it is read, so a receiver - // that keeps its body open cannot hold the connection. Tracing stays off, as it does for - // real exports, because request spans record the URL and custom credential headers. + // Close after reading status so open bodies cannot hang; disable tracing to keep URLs and credentials out of spans. const response = yield* HttpClient.withScope(httpClient) .post(url, { body, headers }) .pipe( diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1fcfd85a5bb67..7da5f98a1d239 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -9,10 +9,7 @@ import { rpcRequestDuration, rpcRequestsTotal, withMetrics } from "./Metrics.ts" type WsRpcMethod = RpcGroup.Rpcs["_tag"]; -/** - * Trace queries and dashboards group by these labels, so they keep their historical values - * even where they differ from the method prefix. - */ +// Trace queries and dashboards depend on these labels, including deviations from method prefixes. const RPC_AGGREGATES = { [WS_METHODS.serverSuggestWorktreeCleanupIgnoredNames]: "server", [WS_METHODS.serverGetResourceUsage]: "server", @@ -104,6 +101,9 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.searchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: "orchestration", @@ -175,6 +175,7 @@ const RPC_AGGREGATES = { [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", [WS_METHODS.secretsAnswerRequest]: "secrets", + [WS_METHODS.guestShareCreate]: "guest-share", [WS_METHODS.cloudGetRelayClientStatus]: "cloud", [WS_METHODS.cloudInstallRelayClient]: "cloud", [WS_METHODS.pullRequestsList]: "pull-requests", @@ -310,10 +311,7 @@ const RPC_METHODS_WITH_TRACING_DISABLED: ReadonlySet = new Set([ WS_METHODS.serverSignalProcess, ]); -/** - * `ws.ts` adds it to the server's group after `RpcScopeAuthorization`, so it wraps - * authorization and also records rejected calls. - */ +/** Add after RpcScopeAuthorization to wrap authorization and record rejected calls. */ export class RpcInstrumentation extends RpcMiddleware.Service()( "t3/server/RpcInstrumentation", ) {} diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 685d901e170c6..4925b837ea923 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -53,6 +53,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; @@ -398,6 +399,151 @@ function assertReinjectedInstructions( } describe("AcpAdapterV2", () => { + it.live.each(["details", "none"] as const)( + "fails the turn on an ACP v2 idle error with %s, settles its open tool, and accepts the next prompt", + (details) => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make(`idle-error-${details}`); + const threadId = ThreadId.make(`thread-idle-error-${details}`); + const childSessionId = "mock-child-session-idle-error"; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + extractBackgroundTaskId: (toolCall) => + toolCall.toolCallId === "tool-call-idle-error-monitor" + ? "idle-error-monitor" + : undefined, + extractSubagentUpdate: (toolCall) => + toolCall.toolCallId === "tool-call-idle-error-subagent" + ? { + nativeTaskId: "task-idle-error", + prompt: "child work", + title: "child work", + model: null, + status: toolCall.status === "completed" ? "completed" : "running", + childSessionId, + result: null, + suppressNormalTool: true, + } + : undefined, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + environment: { T3_ACP_IDLE_ERROR: details }, + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make(`session-idle-error-${details}`), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const runTurn = (ordinal: number) => + Effect.gen(function* () { + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal, + }), + ); + return yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + }); + + const failedEvents = yield* runTurn(1); + const failed = failedEvents.at(-1); + assert.equal(failed?.type, "turn.terminal"); + if (failed?.type !== "turn.terminal" || failed.status !== "failed") { + return assert.fail("expected a failed terminal"); + } + assert.isTrue( + failed.childTurnsContinue, + "the run keeps ingesting work that outlives the failed prompt", + ); + const lateSubagent = yield* runtime.events.pipe( + Stream.filter( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ), + Stream.runHead, + Effect.timeoutOption("2 seconds"), + ); + assert.isTrue( + Option.isSome(lateSubagent) && Option.isSome(lateSubagent.value), + "a subagent finishing after the failed prompt still reports to the run", + ); + assert.deepEqual( + failed.failure, + details === "details" + ? { + class: "provider_error", + message: "Missing Authentication header", + code: "-32000", + retryable: null, + } + : { + class: "provider_error", + message: "Provider turn failed.", + code: null, + retryable: null, + }, + ); + const toolItems = (nativeId: string) => + failedEvents.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [event.turnItem] + : [], + ); + const toolStatuses = toolItems("tool-call-idle-error-1").map((item) => item.status); + assert.include(toolStatuses, "running"); + assert.equal(toolStatuses.at(-1), "failed"); + assert.deepEqual( + toolItems("tool-call-idle-error-monitor").map((item) => item.status), + ["running"], + "a background monitor outlives the failed turn and keeps its running row", + ); + const childItems = toolItems("task-idle-error:tool:tool-call-idle-error-child"); + assert.deepEqual( + childItems.map((item) => item.status), + ["running"], + "a child-session tool keeps running in its own thread", + ); + assert.isTrue(childItems.every((item) => item.threadId !== threadId)); + assert.isEmpty( + toolItems("tool-call-idle-error-child"), + "the failed turn must not reproject the child tool as a root tool", + ); + + const nextEvents = yield* runTurn(2); + const next = nextEvents.at(-1); + assert.equal(next?.type === "turn.terminal" ? next.status : undefined, "completed"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // Factory Droid 0.243.0 sends an optional text companion for every AskUser choice. const droidChoice = { type: "string", @@ -1225,8 +1371,7 @@ describe("AcpAdapterV2", () => { ); type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; let handler: Parameters[0] | undefined; - // Devin streams a file write as it generates it: each update resends the - // whole file so far, a few characters longer, with no status. + // Devin resends the growing file without status on each update. const file = `${"x = 1\n".repeat(800)}END_OF_FILE\n`; const chunks = 200; const streamedWrite = ( @@ -3089,6 +3234,72 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("settles a Grok background command that completed before an ACP v2 idle error", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-idle-error-background"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_IDLE_ERROR: "grok-background" }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), + }); + const threadId = ThreadId.make("grok-idle-error-background"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-idle-error-background"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = events.at(-1); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : undefined, "failed"); + const command = events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "call-idle-error-background", + ); + assert.equal( + command?.type === "turn_item.updated" ? command.turnItem.status : undefined, + "completed", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + // Oracle: Grok incident frames. Command completion must release the turn even // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. it.live( @@ -3177,8 +3388,91 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // Oracle: #15888 KillTask reports. Command completion must release the turn even - // when the CLI keeps the prompt RPC and process alive. Kill calls stay visible. + // Grok expands only a leading `/name`, so a picked leading `$name` + // must reach it in that form; anything else keeps the user's text. + it.live.each([ + { text: "$deploy ship it", sent: "/deploy ship it", native: true }, + { text: "ship $deploy", sent: "ship $deploy", native: false }, + { text: "$other ship it", sent: "$other ship it", native: false }, + ])("Grok sends $text with a picked skill as $sent", ({ text, sent, native }) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const hostPlatform = yield* HostProcess.Platform; + // Stands in for `grok inspect --json`, the catalog behind the `$` picker. + const catalog = { skills: [{ name: "deploy", source: { path: "/s/deploy/SKILL.md" } }] }; + const grokPath = writeFakeCli({ + directory: yield* fileSystem.makeTempDirectoryScoped(), + name: "grok", + source: `process.stdout.write(${JSON.stringify(`${JSON.stringify(catalog)}\n`)});\n`, + platform: hostPlatform, + }); + const protocolEvents = yield* Queue.unbounded(); + const instanceId = ProviderInstanceId.make("grok-skill-mention"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: { ...DEFAULT_GROK_SETTINGS, binaryPath: grokPath }, + // The fake CLI needs `node` on its PATH. + environment: { ...process.env }, + hostPlatform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe( + Effect.flatMap(makeXAiPromptCompletionRuntime), + ), + }); + const threadId = ThreadId.make("grok-skill-mention"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-skill-mention"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + messageText: text, + }), + ); + const prompt = yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const blocks = rawProtocolPromptTextBlocks(Option.getOrThrow(prompt)); + if (native) { + // Same shape as a typed native command: one bare text block, no T3 instructions. + assert.deepEqual(blocks, [{ type: "text", text: sent }]); + } else { + assert.isTrue(blocks.some((block) => block.text.includes(sent))); + assert.isFalse(blocks.some((block) => block.text.trimStart().startsWith("/"))); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + // Command completion must release the turn even when the CLI keeps the prompt RPC and process + // alive. Kill calls stay visible. it.live.each([ { shape: "output_only", expectedCommand: { type: "command_execution", status: "completed" } }, { shape: "variant_only", expectedCommand: { type: "command_execution", status: "completed" } }, @@ -4634,8 +4928,7 @@ describe("AcpAdapterV2", () => { assert.isTrue(yield* hasPendingBackgroundWork); assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); - // A production Grok spawn ACK has raw tool status completed but extracts - // as a running subagent with its original non-empty prompt. + // Grok reports spawn ACKs as completed tools with running subagents and non-empty prompts. yield* sessionUpdateHandler!({ sessionId: "mock-session-1", update: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts index 6f8333dd27fbc..bb74f75bd3463 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.testkit.ts @@ -203,8 +203,7 @@ export function makeAcpReplayRuntime(input: { EffectAcpErrors.AcpError, Crypto.Crypto | Scope.Scope > { - // Recorded transcripts outgrow the kernel's 128 KiB limit on one environment - // variable, so the agent reads the transcript from a file beside its status. + // Transcripts exceed the kernel's 128 KiB per-environment-variable limit. const transcriptPath = `${input.statusPath}.transcript.json`; return (runtimeInput) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index 1ea491b76b71c..3ec304b0a9a59 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -81,10 +81,7 @@ export interface AntigravityAdapterV2Options { readonly continuationRequests?: Parameters[0]["continuationRequests"]; } -/** - * ACP 1.1.1 exposes subagent invocations as ordinary `start_subagent` tools without child - * session ids. - */ +/** ACP 1.1.1 reports `start_subagent` tools without child session ids. */ const extractAntigravitySubagentUpdate: NonNullable = ( toolCall, ) => { @@ -158,8 +155,7 @@ export function makeAntigravityAcpAdapterFlavor( runtimeHarness: "Antigravity", capabilities: AntigravityProviderCapabilitiesV2, makeRuntime, - // Loading history replays every tool call; resume restores the session - // without the replay and is what the official client does. + // Loading history replays tool calls; resume avoids replay. preferResumeSession: true, subagentsIdleOnTurnCompletion: true, ...(options.onSessionEvent === undefined ? {} : { onSessionEvent: options.onSessionEvent }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 887bc020e53ca..6ff8a128cc28a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -35,6 +35,7 @@ import { type ModelSelection, NodeId, type OrchestrationV2AppThread, + type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderThread, ProjectId, ProviderInstanceId, @@ -288,6 +289,40 @@ describe("ClaudeAdapterV2 runtime query policy", () => { ); }); + it("pins Claude's auto tool search on through flag settings", () => { + const override = ClaudeAdapterV2.claudeToolSearchOverride; + for (const value of ["auto", "auto:10", "auto:99.5", "auto:x", "auto:Infinity"]) { + assert.equal(override(value), "true", value); + } + for (const value of [ + undefined, + "true", + "false", + "auto:0", + "auto:-5", + "auto:100", + "auto:1e2", + "auto:1_000", + "auto:1,000", + "AUTO", + ]) { + assert.isUndefined(override(value), String(value)); + } + + const options = ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + nativeThreadId: "native-tool-search", + resume: false, + cwd: "/workspace", + sdkSettings: { env: { OTHER: "kept" } }, + toolSearchOverride: "true", + }); + assert.deepEqual((options.settings as { env?: Record }).env, { + OTHER: "kept", + ENABLE_TOOL_SEARCH: "true", + }); + }); + it("projects AskUserQuestion input with question text as the answer key", () => { const preview = "\n **Proposed change**\n\n```ts\n\tconst enabled = true;\n```\n "; assert.deepEqual( @@ -582,6 +617,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(systemPrompt.type, "preset"); assert.equal(systemPrompt.preset, "claude_code"); assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); + assert.include(systemPrompt.append ?? "", "call `t3_background_wait`"); const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); @@ -1103,7 +1139,6 @@ describe("ClaudeAdapterV2 resumeThread", () => { }); const projectDir = path.join(configDir, "projects", "-workspace"); yield* fileSystem.makeDirectory(projectDir, { recursive: true }); - // What a CLI stopped before it saved the first prompt leaves behind. yield* fileSystem.writeFileString( path.join(projectDir, "native-thread-empty.jsonl"), [ @@ -1805,7 +1840,6 @@ function makeFakeClaudeCli(answerSetServers = true) { }); return child; }; - // The SDK closes the CLI by ending its stdin. const closed = () => stdins.length > 0 && stdins.every((stdin) => stdin.writableEnded); return { spawn, spawns, controlRequests, closed }; } @@ -2262,7 +2296,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const STALE_TASK_NOTIFICATION_RESULT_TEXT = "Stale task-notification origin text that must not appear."; - // Shape seen live after interrupt recovery: zero turns, no terminal_reason. const staleTaskNotificationResult = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000106", result: STALE_TASK_NOTIFICATION_RESULT_TEXT, @@ -2363,6 +2396,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const providerThreadReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2513,6 +2550,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "plan.updated") { yield* Queue.offer(planReceipts, event); } + if (event.type === "provider_thread.updated") { + yield* Queue.offer(providerThreadReceipts, event); + } if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } @@ -2556,6 +2596,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { systemNoticeReceipts, runtimeRequestReceipts, planReceipts, + providerThreadReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -3023,6 +3064,96 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); + + it.effect("pins tool search on when the user's Claude settings choose auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-tool-search-", + }); + const openedToolSearch = ( + settingsJson: string, + cwd = CLAUDE_TEST_RUNTIME_POLICY.cwd, + environment?: NodeJS.ProcessEnv, + ) => + Effect.scoped( + Effect.gen(function* () { + yield* fileSystem.writeFileString(path.join(home, "settings.json"), settingsJson); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: home }, + ...(environment === undefined ? {} : { environment }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-tool-search"), + text: "Run git status.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...CLAUDE_TEST_RUNTIME_POLICY, + cwd, + }), + }), + ); + const settings = harness.getOpenedOptions()?.settings; + return typeof settings === "object" ? settings.env?.ENABLE_TOOL_SEARCH : undefined; + }), + ); + assert.equal(yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"auto"}}'), "true"); + assert.isUndefined(yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"false"}}')); + assert.isUndefined(yield* openedToolSearch("{}")); + + // Claude Code keeps the local file at the repository root, the main checkout's root in a + // worktree, and that file beats the user's settings. + const repo = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-repo-" }); + const worktree = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wt-" }); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "wt"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + yield* fileSystem.makeDirectory(path.join(repo, ".claude")); + yield* fileSystem.makeDirectory(path.join(repo, "packages", "app"), { recursive: true }); + const rootLocal = (value: string) => + fileSystem.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + `{"env":{"ENABLE_TOOL_SEARCH":"${value}"}}`, + ); + const userAuto = '{"env":{"ENABLE_TOOL_SEARCH":"auto"}}'; + yield* rootLocal("auto"); + assert.equal(yield* openedToolSearch("{}", worktree), "true"); + yield* rootLocal("false"); + assert.isUndefined(yield* openedToolSearch(userAuto, worktree)); + assert.isUndefined(yield* openedToolSearch(userAuto, path.join(repo, "packages", "app"))); + + // Project settings beat user settings, and the process env applies only when no file sets it. + const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-project-" }); + yield* fileSystem.makeDirectory(path.join(project, ".claude")); + yield* fileSystem.writeFileString( + path.join(project, ".claude", "settings.json"), + '{"env":{"ENABLE_TOOL_SEARCH":"auto"}}', + ); + assert.equal( + yield* openedToolSearch('{"env":{"ENABLE_TOOL_SEARCH":"false"}}', project), + "true", + ); + assert.equal( + yield* openedToolSearch("{}", CLAUDE_TEST_RUNTIME_POLICY.cwd, { + ENABLE_TOOL_SEARCH: "auto", + }), + "true", + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); }); describe("Plan-mode tool approval", () => { @@ -4585,7 +4716,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (terminal.status !== "failed") return; assert.include(terminal.failure.message, "run `claude auth login`"); assert.include(terminal.failure.message, configDir); - // The message quotes its paths, which escapes Windows separators. assert.include(terminal.failure.message, JSON.stringify(cwd)); assert.notInclude(terminal.failure.message, "repeated API errors"); }).pipe( @@ -4645,7 +4775,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { } return event.providerTurn; }; - // Rollback closes the live query and waits for its message stream to end. const makeRollbackHarness = makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), }); @@ -4805,7 +4934,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () => Effect.gen(function* () { - // The continuation turn reopens the query, and rollback shut the first queue down. const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Queue.shutdown(sdkMessages), freshQueueOnReopen: true, @@ -4882,7 +5010,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]); const next = yield* Queue.take(harness.terminalReceipts); assert.equal(next.status, "completed"); - // The reopened query must resume from the whole transcript, not pin a subagent uuid. assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt); }).pipe( Effect.provide( @@ -4937,11 +5064,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - // Claude reports a compaction's outcome on a status frame. A /compact - // then ends with a success result naming the command and no terminal - // reason, even when the compaction failed (#19327). The automatic case - // omits the terminal reason too, so only the missing command keeps it - // completed. + // Failed /compact reports success without a terminal reason; status frames carry outcomes, including automatic compaction. const result = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000613", result: compact ? "" : "Done.", @@ -4996,15 +5119,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { const [running, settled] = updates; assert.equal(running?.status, "running"); assert.equal(settled?.status, compaction === "success" ? "completed" : "failed"); + assert.equal( + settled?.type === "compaction" ? settled.summary : undefined, + compaction === "failed" + ? limited + ? compactError + : "Not enough messages to compact." + : undefined, + ); assert.equal(settled?.id, running?.id); assert.equal(settled?.ordinal, running?.ordinal); assert.deepEqual(settled?.startedAt, running?.startedAt); assert.isNotNull(settled?.completedAt); assert.isEmpty(harness.terminalEvents()); - yield* Queue.offer( + yield* Queue.offerAll( harness.sdkMessages, - compact ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + compact + ? [ + // Claude Code echoes the command's stdout as a synthetic assistant message. + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000615", + text: "Compacted.", + }), + local_command_source: "Compacted.", + }), + claudeSdkFrame({ ...result, local_command: "compact" }), + ] + : [result], ); const terminal = yield* Queue.take(harness.terminalReceipts); assert.deepEqual( @@ -5016,6 +5159,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { updates, ); assert.equal(terminal.status, status); + assert.equal( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + !compact, + ); if (terminal.status !== "failed") return; assert.equal(terminal.failure.class, "usage_limit"); assert.equal(terminal.failure.message, compactError); @@ -5030,6 +5180,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps an unreported /compact failure's reason on the compaction, not as a reply", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-compaction-unreported"), + text: "/compact", + attachments: [], + }), + ); + // Claude can refuse a /compact without a compact_result: the only reason is the result text. + yield* Queue.offerAll(harness.sdkMessages, [ + claudeSdkFrame({ + type: "system", + subtype: "status", + status: "compacting", + uuid: "00000000-0000-4000-8000-000000000620", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000622", + text: "Not enough messages to compact.", + }), + local_command_source: + "Not enough messages to compact.", + }), + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000621", + result: "Not enough messages to compact.", + numTurns: 0, + terminalReason: null, + }), + local_command: "compact", + }), + ]); + yield* Queue.take(harness.terminalReceipts); + const settled = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(settled?.status, "failed"); + assert.equal(settled?.summary, "Not enough messages to compact."); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each([ { name: "parent limit", parentErrors: ["rate_limit"], expectedLimit: true }, { @@ -5308,7 +5522,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { usage: { ...zeroUsage, input_tokens: 37_939, output_tokens: 184 }, }), usageSnapshotFrame("msg_first", "00000000-0000-4000-8000-000000000671", zeroUsage), - // The next request starts at zero again and its delta reports only output. usageStreamFrame({ type: "message_start", message: { id: "msg_second", usage: zeroUsage }, @@ -5359,13 +5572,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { id: "msg_native", usage: startUsage }, }), usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000680", startUsage), - // Output-only deltas keep the input and cache counts from message_start. usageStreamFrame({ type: "message_delta", delta: { stop_reason: null, stop_sequence: null }, usage: { input_tokens: null, output_tokens: 20 }, }), - // A delta that restates input (server tools) replaces it, explicit zeros included. usageStreamFrame({ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, @@ -5384,7 +5595,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000000682", session_id: WAKE_NATIVE_SESSION, }), - // Usage for the message before the boundary no longer describes the context. usageSnapshotFrame("msg_native", "00000000-0000-4000-8000-000000000684", startUsage), usageStreamFrame({ type: "message_delta", @@ -7100,8 +7310,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Frames follow #19832: a full context window ends the turn with - // blocking_limit while the plan's rate-limit window is still allowed. + // Context exhaustion reports blocking_limit even when the rate-limit window is allowed. it.effect.each([ { windowStatus: "allowed", @@ -7214,7 +7423,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { WAKE_TASK_ID, ); - // A subagent's own background shell keeps the subagent waiting on it. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -7256,6 +7464,132 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("holds only the background tasks the agent marks awaited, until they end", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; + if (awaitBackgroundTasks === undefined) { + throw new Error("Claude adapter runtime must expose awaitBackgroundTasks."); + } + const roster = (tasks: ReadonlyArray, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: tasks.map((taskId) => ({ + task_id: taskId, + task_type: "local_bash", + description: taskId, + })), + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const nextRoster = Effect.fn("nextRoster")(function* ( + matches: (tasks: ReadonlyArray) => boolean, + ) { + while (true) { + const { providerThread } = yield* Queue.take(harness.providerThreadReceipts); + const tasks = providerThread.pendingBackgroundTasks ?? []; + if (matches(tasks)) return tasks; + } + }); + const marks = (tasks: ReadonlyArray) => + tasks.map((task) => [task.taskId, task.awaited === true]); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-awaited-roster"), + text: "Run the tests in the background and the dev server.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + roster([WAKE_TASK_ID, "dev-server"], "00000000-0000-4000-8000-000000000301"), + ); + yield* nextRoster((tasks) => tasks.length === 2); + + const marked = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: [WAKE_TASK_ID, "already-finished"], + awaited: true, + }); + assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + [WAKE_TASK_ID, true], + ["dev-server", false], + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + // A replacing level rebuilds every entry; the mark must survive it. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), + ); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks[0]?.taskId === "dev-server")), + [ + ["dev-server", false], + [WAKE_TASK_ID, true], + ], + ); + + // Once the task leaves the roster, a reused id starts unmarked. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server"], "00000000-0000-4000-8000-000000000303"), + ); + yield* nextRoster((tasks) => tasks.length === 1); + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000304"), + ); + assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ]); + + yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: true, + }); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + ["dev-server", true], + [WAKE_TASK_ID, false], + ], + ); + const released = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: false, + }); + assert.deepStrictEqual(released.matched, ["dev-server"]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), + [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("keeps a running Claude monitor typed after many newer monitors end", () => Effect.scoped( Effect.gen(function* () { @@ -7638,8 +7972,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; let allocateIndex = 0; - // Real two-process model: each openQuery owns its own message queue. - // A shared queue would mask sibling process death on replacement. + // A shared queue would hide sibling process death on replacement. const processQueues: Array<{ readonly nativeThreadId: string; readonly queue: Queue.Queue; @@ -8817,8 +9150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("fails a prompt Claude completes without starting a turn for it", () => Effect.scoped( Effect.gen(function* () { - // Claude answers a prompt whose uuid its transcript already holds with - // a lone completed lifecycle frame and never runs a turn for it. + // Claude answers previously recorded prompt uuids with only a completed lifecycle frame. const harness = yield* makeWakeHarness; const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -9242,9 +9574,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* harness.until(() => harness.offeredMessages.length === 1); - // Live interleaving seen after interrupt recovery: a stale stopped - // task_notification and its task-notification-origin result arrive - // before the real root assistant stream. + // After interrupt recovery, stale task notifications and their results can precede the root stream. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -10740,8 +11070,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); const frames = [ - // The SDK can forward child frames before the task_started that - // registers their subagent. + // Child frames can precede the task_started that registers their subagent. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000321", @@ -11555,8 +11884,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const toolUseId = "toolu-subagent-model"; const parentModel = "claude-opus-4-6"; - // Claude stamps its own error frames, such as a usage-limit notice, as - // ""; that is not the model the subagent runs on. + // Claude stamps its error frames as "", regardless of the subagent model. const observedModel = source === "synthetic-after" ? "" : "claude-haiku-4-5-20251001"; yield* harness.runtime.startTurn( @@ -12209,9 +12537,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); - // Shaped like the recorded SendMessage ACK in - // claude_background_subagent_lifecycle: the text block is the JSON of - // tool_use_result, and "message" names the agent's short id. const resumeAck = { success: true, message: `Resuming agent ${SUBAGENT_TASK_ID.slice(0, 7)}`, @@ -13460,7 +13785,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageMark: true, itemMark: true, }); - // A brief is marked as not a script, so its text is never read as one. assert.deepEqual(subagentPrompt("task-plain-auditor"), { text: "Audit the last five commits.", messageMark: false, @@ -14601,7 +14925,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - // Line shapes copied from a Claude CLI 2.1.301 workflow member transcript. const transcriptLine = (entry: Record) => `${JSON.stringify({ isSidechain: true, agentId: "a1", timestamp: "2026-10-17T05:08:07.524Z", ...entry })}\n`; const memberAssistantLine = ( @@ -14914,8 +15237,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.scoped( Effect.gen(function* () { const harness = yield* makeWorkflowHarness; - // About 1.3 MB of early replies: more than the launch frame's read - // and the settled frame's read together, at 512 KB each. + // Exceeds the launch and settle reads combined, at 512 KB each. const filler = "x".repeat(16 * 1024); NodeFS.writeFileSync( NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), @@ -15277,8 +15599,7 @@ describe("ClaudeAdapterV2 query message stream", () => { let closed = false; let releaseRead = () => {}; const readStarted = Promise.withResolvers(); - // Never yields a message — the read stays pending until close() flips - // `closed` and releases the in-flight await. + // Keep the read pending until close() releases it. // oxlint-disable-next-line require-yield async function* sdkMessages(): AsyncGenerator { for (;;) { @@ -15315,9 +15636,7 @@ describe("ClaudeAdapterV2 query message stream", () => { ); yield* Effect.promise(() => readStarted.promise); - // Iterating query[Symbol.asyncIterator]() directly deadlocks here: - // the raw generator's return() queues behind the in-flight read and - // scope close never completes. + // The raw async generator's return() queues behind its pending read and deadlocks scope close. yield* Scope.close(scope, Exit.void); assert.isTrue(closed); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index ec3ec5b052461..5240ee5ea7621 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -331,8 +331,7 @@ function isClaudeSdkReplayMessage(frame: unknown): frame is SDKMessage { type === "system" || type === "stream_event" || type === "rate_limit_event" || - // Undeclared in the SDK types: queued/started/completed for each prompt - // that carries a uuid. + // The SDK types omit command_lifecycle for prompt uuids. type === "command_lifecycle" ); } @@ -654,10 +653,7 @@ function makeReplayQueryRunner( }, }); - // Prompt uuids are derived from ids that differ between the recording and - // a replay run, so a matched prompt offer maps the recorded uuid to the - // replayed one, and inbound frames echoing or acknowledging it are - // rewritten to match. + // Prompt uuids differ on replay; rewrite inbound echoes and acknowledgements to match. const promptUuidReplacements = new Map(); const replayedPromptUuid = (value: string): string => promptUuidReplacements.get(value) ?? value; const withReplayedPromptUuids = (frame: unknown): unknown => { @@ -994,12 +990,7 @@ function layerClaudeAgentSdkReplay( ); } -/** - * While a foreground Bash call runs, Claude Code writes its output to a task - * file the adapter tails. A replay has no Claude Code process, so it writes the - * file each recorded `task_started` names, holding that call's recorded output, - * under a scenario-owned `CLAUDE_CODE_TMPDIR`. - */ +/** Replays write recorded Bash output to task_started files under a scenario-owned CLAUDE_CODE_TMPDIR. */ const writeReplayTaskOutputFiles = Effect.fn("ClaudeReplay.writeTaskOutputFiles")(function* ( transcript: ClaudeAgentSdkReplayTranscript, ) { @@ -1204,8 +1195,7 @@ function sanitizeSdkMessageForReplay(input: { session_id: message.session_id, }; } - // Like init's slash_commands, the recording account's commands and skills - // are local configuration, not protocol. + // Recorded commands and skills are account configuration. if (message.type === "system" && message.subtype === "commands_changed") { return { ...message, commands: [] }; } @@ -1499,12 +1489,8 @@ async function recordMessagesUntilToolUse(input: { } } -// The SDK package may not ship a Claude Code executable for this platform, so -// recordings prefer the installed `claude` binary when it resolves on PATH to -// something the SDK can spawn directly, and otherwise leave the SDK's own -// executable discovery in place. A Windows launcher shim (`claude.cmd` and -// friends) is not directly spawnable, so it only counts when -// resolveClaudeSdkExecutablePath can follow it to a real package entry. +// The SDK may lack a platform executable. Prefer installed Claude only when directly spawnable, +// including Windows shims resolved to package entries. const resolveClaudeRecordingExecutablePath = Effect.fn("resolveClaudeRecordingExecutablePath")( function* (environment: NodeJS.ProcessEnv) { const resolveExecutable = yield* SpawnExecutableResolution; @@ -1653,9 +1639,7 @@ async function recordClaudeStreamingQuery(input: { frame: makeClaudePromptOfferFrame(message), }); promptQueue.offer(message); - // A task notification that lands during a turn queues a wake turn the - // CLI can run before this prompt's turn, so results are told apart by - // origin rather than by arrival order. + // Wake turns can precede the prompt turn; identify results by origin. let promptSettled = false; let wakes = 0; const expectedWakes = input.backgroundWakeCounts?.[index] ?? 0; @@ -1667,8 +1651,7 @@ async function recordClaudeStreamingQuery(input: { } } if (input.backgroundWakeCounts !== undefined && input.offerNextPromptImmediately !== true) { - // Every turn opens with system:init; frames from still-running - // subagents are left for the next prompt's recording. + // system:init opens each turn; leave ongoing subagent frames for the next recording. while (isSystemInitFrame(await iterator.peekWithin(CLAUDE_RECORDING_WAKE_QUIET_MS))) { await recordTurn(index + 1); } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 45eab2fe3003b..de21b60dde1a2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -65,6 +65,7 @@ import { getModelSelectionBooleanOptionValue, } from "@t3tools/shared/model"; import { + CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, formatClaudeResumeCompactionQuestion, } from "@t3tools/shared/claudeCompaction"; @@ -73,6 +74,7 @@ import { ClaudeSettings, TrimmedNonEmptyString, defaultInstanceIdForDriver, + isCompactionCommandMessage, type ModelSelection, type OrchestrationV2ConversationMessage, type OrchestrationV2ExecutionNode, @@ -287,8 +289,7 @@ export function claudeProviderTurnTokenUsage( type ClaudeMessageUsage = Parameters[0]; -// message_delta usage is cumulative for its message. Fields it leaves null or -// absent keep their message_start value; explicit zeros replace it. +// message_delta usage is per-message cumulative; null or absent fields retain message_start values and zeros replace them. function mergeClaudeStreamUsage( current: ClaudeMessageUsage, delta: { @@ -310,10 +311,8 @@ function mergeClaudeStreamUsage( } export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER); const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); -// Entry extensions the Claude SDK runs through node instead of executing. const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; -// The SDK puts no deadline on control requests. The CLI answers -// mcp_set_servers once each server connected or hit its own 30 s timeout. +// The SDK has no control-request deadline; the CLI waits up to 30 s per MCP server. const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; // Claude can consume a subagent result before the launching turn ends, leaving // its notification with no native wake turn to answer the continuation. @@ -332,16 +331,9 @@ const CLAUDE_GROUP_WATCH_SCRIPT = [ ].join("\n"); /** - * When the CLI exits it stops the MCP servers it started, but not their children: a plugin's - * `bun run start` wrapper dies and its `bun server.ts` keeps running, writing to a pipe nobody - * reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the - * CLI exits for any reason. `onExit` then stops the CLI's agent scope, for daemons a tool - * detached from the group with setsid. - * - * That cleanup needs this server alive, and the CLI finishes its turn after its pipes close. A - * server that crashes or is killed would leave the CLI working next to the session it resumes - * (#23370), so a watcher in the group reads a pipe only this server holds and stops the group at - * its end-of-file. + * Kill the CLI's group on exit to stop MCP grandchildren; `onExit` stops its agent scope for + * setsid daemons. The CLI keeps working after its pipes close, so a watcher stops the group on + * EOF from a pipe only this server holds, including when the server crashes. */ export function spawnClaudeProcessGroup( options: ClaudeSpawnOptions, @@ -638,10 +630,7 @@ function closeClaudeQuery(queryRuntime: ClaudeQuery) { }); } -// Iterate the Query itself, not query[Symbol.asyncIterator]() (the raw -// sdkMessages generator). The raw generator's return() queues behind the -// in-flight read of the next CLI message and never settles while the CLI is -// idle, deadlocking stream interruption (and with it, session scope close). +// Iterate Query itself: the raw generator's return() queues behind a pending CLI read and deadlocks interruption. export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable { return { [Symbol.asyncIterator]: () => queryRuntime }; } @@ -932,14 +921,9 @@ export const layerQueryRunner: Layer.Layer< ), Stream.toAsyncIterable, ); - // The SDK would pass `mcpServers` as an inline `--mcp-config` argument, - // which every local user can read. They go over the CLI's stdin control - // channel instead, so header credentials stay out of its argv and out - // of the environment every command the agent runs inherits. + // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. const { mcpServers, ...queryOptions } = input.options; - // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, - // which lets the agent scope wrapper go in front of the CLI. The SDK - // runs a script entry through node instead, so those launch unwrapped. + // The SDK can prepend a scope wrapper to native binaries; script entries run through node. const binaryPath = queryOptions.pathToClaudeCodeExecutable; const launch = binaryPath === undefined || @@ -986,8 +970,7 @@ export const layerQueryRunner: Layer.Layer< restore( Effect.gen(function* () { if (mcpServers !== undefined) { - // Error texts and the CLI's mcp_status response can echo the headers, so - // only server names are logged. + // Errors and mcp_status can echo headers; log only server names. const failed = yield* Effect.tryPromise(() => queryRuntime.setMcpServers(mcpServers), ).pipe( @@ -1261,23 +1244,15 @@ export const layerQueryRunner: Layer.Layer< }), ); -/** - * Claude Code's Bash tool keeps a `cd` across calls and its tool description - * says to prefer absolute paths, so agents prefix commands with - * `cd /abs/worktree` or spell out full paths. CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR - * returns the shell to the session cwd (or a worktree subagent's own - * worktree) after every command; the prompt note is what changes the habit, - * since the built-in description still claims the directory persists. +/** Claude's Bash description claims cwd persists and encourages absolute paths. + * This setting resets cwd after each command, including in subagent worktrees; + * the prompt note must also correct the habit encouraged by that description. */ const CLAUDE_SHELL_ENVIRONMENT = { CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR: "1" }; const CLAUDE_SHELL_INSTRUCTIONS = `\n\nEvery Bash command starts in the thread's working directory, and a \`cd\` does not carry over to the next command, whatever the Bash tool description says. Run commands directly with paths relative to it. For a subdirectory, use the tool's own directory flag (\`git -C\`, \`npm --prefix\`) or \`cd dir && cmd\` within that single command.`; type ClaudeFlagSettings = Parameters[0]; -/** - * Thinking is a launch flag (`--thinking`), so only a new process can change - * it. Clearing an explicit effort also needs one: the live reset goes to the - * model default, while a new process reads the user's settings files. - */ +/** Thinking requires a new process. Clearing effort also requires one to reload user settings instead of model defaults. */ function canApplyClaudeSelectionLive( live: CompiledClaudeModelSelection, next: CompiledClaudeModelSelection, @@ -1316,6 +1291,44 @@ export function claudeQueryEnvironment( return { ...environment, CLAUDE_CODE_PROMPT_CACHE_TTL: subagentPromptCacheTtl }; } +// Claude Code's auto tool search sends every MCP schema in full while they fit under a share of the +// context window, which T3's tools do on 1M models. auto:0 already means on and auto:100 off. +export function claudeToolSearchOverride(value: string | undefined): "true" | undefined { + if (value === undefined) return undefined; + if (value === "auto") return "true"; + if (!value.startsWith("auto:")) return undefined; + const percent = parseClaudeInteger(value.slice("auto:".length)); + // Claude Code treats an unparsable auto:N as auto, and clamps N to 0..100. + if (Number.isNaN(percent)) return "true"; + const clamped = Math.min(100, Math.max(0, percent)); + return clamped > 0 && clamped < 100 ? "true" : undefined; +} + +// Claude Code 2.1.310's integer parsing for auto:N. Scientific notation counts only when it is an +// integer, grouped digits (1_000, 1,000, or 1 000) are joined, and anything else uses parseInt. +const CLAUDE_SCIENTIFIC_INTEGER = /^[+-]?(\d+(\.\d*)?|\.\d+)[eE][+-]?\d+$/; +const CLAUDE_GROUPED_INTEGER = /^[+-]?\d{1,3}([_,\u00A0\u202F ])\d{3}(?:\1\d{3})*$/; +function parseClaudeInteger(text: string): number { + const value = text.trim(); + if (value.length <= 32 && CLAUDE_SCIENTIFIC_INTEGER.test(value)) { + const number = Number(value); + return Number.isInteger(number) ? number : Number.NaN; + } + if (value.length <= 32 && CLAUDE_GROUPED_INTEGER.test(value)) { + return Number.parseInt(value.replace(/[_,\u00A0\u202F ]/g, ""), 10); + } + return Number.parseInt(value, 10); +} + +// Claude Code defers MCP tools behind ToolSearch, so the agent only finds +// t3_background_wait when the system prompt says when to reach for it. +const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` + +### Background work you wait on + +Before you end your turn to wait for a background command or Monitor you started, such as tests, a build, or a CI wait, call \`t3_background_wait\` with its task ID. The thread then stays in the user's Working list until that task ends, instead of returning to them early. Do not mark a dev server, a watcher, or anything else you leave running. +`; + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -1338,6 +1351,8 @@ export function makeClaudeQueryOptions(input: { readonly allowDangerouslySkipPermissions?: boolean; readonly hooks?: ClaudeQueryOptions["hooks"]; readonly appendSystemPromptFileText?: string; + // Flag settings, because a user's settings.json env beats the process env in Claude Code. + readonly toolSearchOverride?: "true"; }): ClaudeAgentSdkQueryOptions { const compiledSelection = compileClaudeModelSelection(input.modelSelection); const { @@ -1374,13 +1389,21 @@ export function makeClaudeQueryOptions(input: { : typeof input.sdkSettings === "object" && input.sdkSettings !== null ? ({ ...input.sdkSettings, ...selectionSettings } as ClaudeSdkSettings) : selectionSettings; - const effectiveQuerySettings = + const compactQuerySettings = input.settings?.autoCompactWindow === undefined || input.settings.autoCompactWindow.length === 0 ? querySettings : ({ ...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}), autoCompactWindow: Number(input.settings.autoCompactWindow), } as ClaudeSdkSettings); + // A settings file path cannot take extra keys, so it goes through unchanged. + const effectiveQuerySettings = + input.toolSearchOverride === undefined || typeof compactQuerySettings === "string" + ? compactQuerySettings + : ({ + ...compactQuerySettings, + env: { ...compactQuerySettings?.env, ENABLE_TOOL_SEARCH: input.toolSearchOverride }, + } as ClaudeSdkSettings); const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, tools: claudeAgentSdkQueryToolsForSdk(selectedTools), @@ -1435,7 +1458,9 @@ export function makeClaudeQueryOptions(input: { append: [ buildRuntimeInstructions({ harness: "Claude Code" }) + (input.environment === undefined ? "" : CLAUDE_SHELL_INSTRUCTIONS) + - (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), + (input.mcpServers === undefined + ? "" + : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS), ...launchAppendSystemPrompt, ].join("\n\n"), }, @@ -1451,14 +1476,9 @@ export function makeClaudeQueryOptions(input: { return input.cwd === null ? withDirectories : { ...withDirectories, cwd: input.cwd }; } -/** - * Claude Code creates the worktree for an `isolation: "worktree"` subagent - * itself, so T3's worktree setup never sees it. SubagentStart fires after that - * worktree exists and before the subagent's first tool call, with the worktree - * as its cwd; preparing it there gives the subagent the same files, setup - * script, and environment as a thread. `prepared` lives as long as the provider - * session, so each worktree is prepared once. The session cwd is the thread's - * own, already prepared, worktree. +/** Claude creates isolated subagent worktrees outside T3's preparation path. + * SubagentStart runs in that worktree before tools, so prepare it there once per + * provider session; the session's own cwd is already prepared. */ export const makeSubagentWorktreeSetupHooks = Effect.fn("ClaudeAdapterV2.subagentWorktreeHooks")( function* (input: { @@ -1536,8 +1556,7 @@ const readClaudeAppendSystemPromptFile = Effect.fnUntraced(function* (input: { export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; -// Must stay in sync with the Tool.Readonly annotations on OrchestratorToolkit; -// ClaudeAdapterV2.test.ts cross-checks this list against the toolkit. +// Keep in sync with OrchestratorToolkit Tool.Readonly annotations; ClaudeAdapterV2.test.ts checks this. export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", @@ -1561,18 +1580,11 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__html_render", ]; -// Claude Code aborts an HTTP MCP call after 60 s ("The operation timed out.") -// unless the server config sets `timeout`. T3's wait tools (t3_thread_wait, -// delegate_task mode=wait) legitimately block for up to an hour -// (MAX_WAIT_TIMEOUT_MS in OrchestratorMcpService), so the budget sits just -// above that and the server's own wait timeout is what ends a long call. +// T3 waits can block for an hour; exceed Claude's default 60 s timeout. export const CLAUDE_T3_MCP_TOOL_TIMEOUT_MS = 65 * 60 * 1_000; -// The SDK's `allowedTools` only pre-approves tool calls; availability is the separate `tools` -// option. Attaching the t3-code MCP server therefore always pre-approves its tools (headless -// modes like `dontAsk` deny anything that is not pre-approved), but read-only sandboxes -// pre-approve only the annotated read-only orchestrator tools so a read-only session cannot -// silently spawn threads or scheduled tasks. +// allowedTools pre-approves calls; tools controls availability. Pre-approve T3 tools for headless modes, +// limited to annotated read-only tools in read-only sandboxes. export function claudeMcpQueryOverrides(input: { readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -1629,10 +1641,7 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content" return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""); } -// Claude 5.1 and later return the text written ahead of a tool call as a -// thinking block, at most one per call, instead of a text block. Summarized -// display makes it look like reasoning, so only the second of two thinking -// blocks right before a tool call is treated as that text. +// Claude 5.1+ emits at most one pre-tool text block as thinking; treat only a second consecutive thinking block as text. function claudeWritesUpdatesAsThinking(model: string): boolean { const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model); if (match === null) return false; @@ -1640,9 +1649,7 @@ function claudeWritesUpdatesAsThinking(model: string): boolean { return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1); } -// In SDK mode Claude reports `/goal` only through the transcript (its -// `active_goal` event is remote-only): synthetic command output names the -// goal, and each unmet evaluator check returns as Stop hook feedback. +// In SDK mode, /goal appears only in transcripts: command output names it and Stop hooks report unmet checks. const CLAUDE_GOAL_SET_PREFIX = "Goal set: "; const CLAUDE_GOAL_ACTIVE = /^Goal active: ([\s\S]+?) \((?:not yet evaluated|(\d+) turns?)\)/u; @@ -1924,11 +1931,8 @@ export function makeClaudeUserMessage(input: { readonly skills?: ReadonlyMap; readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { - // Claude Code expands a skill only from the LAST text block, and only when - // `/name` is its first character. A `$skill` chip anywhere in the prompt - // therefore becomes [whole request, "/name trailing text"] so the CLI runs - // it natively and the model still reads the full request. See - // ClaudeSkillDispatch. + // Claude expands a skill only when `/name` starts the last text block. + // Keep the whole request before that block; see ClaudeSkillDispatch. const dispatch = input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills); return { @@ -2027,8 +2031,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: }); } - // Images go before the command block: a text block after them still - // expands, a command block followed by an image does not. + // Images must precede the command block for skill expansion. if (dispatch) { content.push({ type: "text", text: dispatch.commandText }); } else if (textWithAttachmentPaths.length > 0) { @@ -2210,8 +2213,7 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy) ? readOnlyTools : undefined; - // acceptEdits approves edits before the callback runs; everything else it - // leaves to the callback, which must ask rather than allow. + // acceptEdits approves edits before the callback; the callback must ask for everything else. const installPermissionCallback = downgraded || (runtimePolicy.approvalPolicy === undefined @@ -2267,6 +2269,14 @@ const decodeClaudeSubagentCacheSettings = Schema.decodeUnknownEffect( ), ), ); +const decodeClaudeEnvSettings = Schema.decodeUnknownEffect( + fromLenientJson( + Schema.StructWithRest( + Schema.Struct({ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)) }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ), +); function claudeBypassAvailability( settings: typeof ClaudeBypassSettings.Type, @@ -2295,9 +2305,7 @@ function claudeRuntimeQueryPolicyKey(policy: ClaudeRuntimeQueryPolicy): string { }); } -// Live-query reuse must key on the effective allowlist (including the -// MCP-derived pre-approvals), not just the runtime policy: policies that -// share a policy key can still differ in MCP pre-approvals. +// Live-query reuse must include MCP pre-approvals, which can differ for the same policy key. export function claudeEffectiveQueryPolicyKey( queryPolicy: ClaudeRuntimeQueryPolicy, mcpOverrides: { @@ -2454,8 +2462,7 @@ function commandInputFromClaudeTool(toolName: string, input: ClaudeNativeToolInp ); } -// Subagents project through the normal subagent lifecycle and must not be double-counted when -// background_tasks_changed includes them. +// background_tasks_changed can include subagents already counted by their lifecycle. const CLAUDE_OPAQUE_BACKGROUND_TASK_KINDS: ReadonlyMap< string, Exclude @@ -2477,8 +2484,7 @@ function isClaudeOpaqueBackgroundTaskType(taskType: string | null | undefined): function claudePendingBackgroundTask(input: { readonly taskId: string; readonly taskType: string | null; - // Claude runs a Monitor as a local_bash task, so only the Monitor tool call - // that started it tells it apart from a background Bash command. + // Only the launching Monitor tool distinguishes a local_bash Monitor from background Bash. readonly startedByMonitor: boolean; readonly description: string | undefined; // Undeclared SDK field: the subagent whose own background work this is. @@ -2530,8 +2536,11 @@ function isClaudeRootTurnFrame(message: SDKMessage): boolean { function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, + awaitedTaskIds: ReadonlySet, ): ReadonlyArray { - return Array.from(roster.values()); + return Array.from(roster.values(), (task) => + awaitedTaskIds.has(task.taskId) ? { ...task, awaited: true as const } : task, + ); } function parseClaudeBackgroundTaskEntry( @@ -2743,12 +2752,8 @@ interface ClaudeSubagentCallUsage { readonly outputTokens: number; } -/** - * A Claude subagent's usage over its whole life. The SDK's task usage counts - * tool calls and time per run, and its total_tokens is only the latest call, - * so tokens are summed from the subagent's own calls instead, matching the - * running totals Codex and delegated tasks report. Shared by reference across - * copies of the subagent's entry. +/** SDK task total_tokens covers only the latest call, so sum the subagent's calls. + * This lifetime tally is shared by reference across copies of the subagent entry. */ interface ClaudeSubagentUsageTally { // This run's calls by message id. Each content block repeats the opening @@ -3373,10 +3378,7 @@ const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(fun return yield* Effect.raceFirst(Deferred.await(answers), cancellation); }); -/** - * "[ede_diagnostic] ..." entries are CLI-internal telemetry (the CLI hides them from its own UI - * too), so they must never become the error banner (#5557). - */ +/** Exclude CLI-internal "[ede_diagnostic] ..." telemetry from error banners. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { const errors = "errors" in result && Array.isArray(result.errors) ? result.errors : []; if (result.subtype === "success" && !result.is_error) { @@ -3408,8 +3410,7 @@ function terminalResultError( case "turn_setup_failed": return "Claude could not start the turn."; case "blocking_limit": - // The CLI also ends a turn with blocking_limit once the context window - // is full ("Prompt is too long"). Only rate-limit evidence sets the hint. + // blocking_limit also means context exhaustion; only rate-limit evidence sets the hint. return failureHint ?? CONTEXT_WINDOW_FULL_ERROR; case "rapid_refill_breaker": return "Claude stopped: the context refilled too quickly after compaction."; @@ -3435,8 +3436,7 @@ function terminalStatusFromResult( OrchestrationV2ProviderTurn["status"], "completed" | "interrupted" | "failed" | "cancelled" > { - // The CLI can label an abort as success with is_error=false. Its explicit - // terminal reason takes precedence over that envelope. + // Explicit abort reasons override success envelopes with is_error=false. if ( message.terminal_reason === "aborted_tools" || message.terminal_reason === "aborted_streaming" @@ -3444,8 +3444,7 @@ function terminalStatusFromResult( return "interrupted"; } if (message.subtype === "success") { - // The SDK reports API-level failures (401 auth, 529 overloaded, …) as - // subtype "success" with is_error set; the turn produced no real work. + // The SDK can report API failures as subtype "success" with is_error set. return isOverloadedResult(message) || message.api_error_status === 429 || terminalResultError(message.terminal_reason, failureHint) !== undefined || @@ -3522,9 +3521,7 @@ function claudePromptLifecycle( return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null; } -// An echo names the prompts its turn consumed. Without one, only a process -// known to echo proves the turn foreign by its non-human origin; on a CLI that -// never echoes, that result can be the prompt turn's only result. +// Without an echo, non-human origin proves a foreign turn only on CLIs known to echo prompts. function isClaudeResultForOtherTurn(input: { readonly message: SDKResultMessage; readonly promptUuid: string; @@ -3844,12 +3841,9 @@ interface ActiveClaudeTurnContext { { readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string } >; }; - // Usage of the latest root request, merged from its stream events. Its - // assistant snapshots repeat message_start usage, which some proxies send - // as zeros, so they only fill in input the stream has not reported. A - // compaction makes the usage stale (null) without forgetting the request. - // `requestedAt` is when a streamed request started; its prompt cache lifetime starts then. - // `outputFinal` is set once message_delta replaces the opening output count. + // Snapshots repeat message_start input, sometimes as zeros, so only fill gaps in streamed usage. + // Compaction clears usage without forgetting the root request. + // `requestedAt` starts the request's prompt cache lifetime. streamUsage: { readonly messageId: string; requestedAt?: DateTime.Utc; @@ -3876,8 +3870,7 @@ interface ActiveClaudeTurnContext { // Set while this turn's latest compaction status reports a failure, with // Claude's error text when it sent one. compactionFailure: { readonly message: string | undefined } | null; - // Usage of the results that steers ended, oldest first. The final result's - // usage covers only its own segment, so the turn bills these as well. + // Steers end segments whose usage is excluded from the final result; bill them too, oldest first. readonly steeredSegmentUsage: Array; readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; @@ -3926,9 +3919,7 @@ interface ClaudeLiveQueryContext { selection: CompiledClaudeModelSelection; readonly closed: Deferred.Deferred; lastTurn: ActiveClaudeTurnContext | null; - // Whether this CLI process echoes a prompt's uuid on the first frame of the turn answering it - // ("early") or only on its result. "acknowledged": the CLI confirmed it took a prompt's uuid - // before any echo, so it echoes, but a resume's own turns can still run ahead of that prompt. + // "early" echoes on the first frame; "acknowledged" confirms uuid acceptance, but resume turns can still precede the prompt. promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; @@ -3983,7 +3974,6 @@ function applyClaudeModelCallStamp( return changed; } -/** Where a subagent's conversation items land: its child thread and root node. */ interface ClaudeSubagentProjectionTarget { readonly childThreadId: ThreadId; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; @@ -3994,13 +3984,8 @@ interface ClaudeSubagentProjectionTarget { carriedModelCallId?: string; } -/** - * Whether a transcript line ends the assistant's turn. Claude Code writes each - * block of a message as its own line, `stop_reason: null` on all but the last, - * and text before a tool call (`tool_use`) is not the reply. A line without the - * field (no CLI writes one) keeps the turn open: waiting costs at most the flush - * window, while ending the turn early can lose the reply. - */ +/** Claude writes blocks separately with stop_reason null until the last. + * Missing stop_reason keeps the turn open; pre-tool text is not the reply. */ function isClaudeTranscriptTurnEnd(entry: unknown): boolean { if (field(entry, "type") !== "assistant") return false; const stopReason = field(field(entry, "message"), "stop_reason"); @@ -4487,6 +4472,73 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, ); + const readToolSearchSetting = (filePath: string) => + fileSystem.readFileString(filePath).pipe( + Effect.flatMap(decodeClaudeEnvSettings), + Effect.map((settings) => { + const value = settings.env?.ENABLE_TOOL_SEARCH; + return typeof value === "string" ? value : undefined; + }), + Effect.orElseSucceed(() => undefined), + ); + // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to + //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. + const mainCheckoutRoot = (checkout: string) => + fileSystem.readFileString(path.join(checkout, ".git")).pipe( + Effect.flatMap((pointer) => { + const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (gitDir === undefined) return Effect.succeed(checkout); + const worktreeGitDir = path.resolve(checkout, gitDir); + return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( + Effect.map((commonDir) => { + const common = path.resolve(worktreeGitDir, commonDir.trim()); + return path.basename(common) === ".git" ? path.dirname(common) : checkout; + }), + ); + }), + Effect.orElseSucceed(() => checkout), + ); + // Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a + // repository, on Windows, or when the root is home. Its owner check is skipped: missing it only + // leaves tool search on, Claude Code's default. + const localSettingsDirectory = Effect.fn("ClaudeAdapterV2.localSettingsDirectory")(function* ( + cwd: string, + ) { + if ((yield* HostProcess.Platform) === "win32") return cwd; + for (let directory = cwd; ; directory = path.dirname(directory)) { + const isCheckout = yield* fileSystem + .exists(path.join(directory, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isCheckout) { + const root = yield* mainCheckoutRoot(directory); + return root === (yield* HostProcess.HomeDirectory) ? cwd : root; + } + if (path.dirname(directory) === directory) return cwd; + } + }); + // Settings env beats the process env, local over project over user, and a local file an older + // Claude Code left in cwd applies under the root one. resolveSettings reads local from cwd only. + const resolveToolSearchOverride = Effect.fn("ClaudeAdapterV2.resolveToolSearchOverride")( + function* (cwd: string | null) { + const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); + const settingsFiles = [ + ...(cwd === null || localDirectory === null + ? [] + : [ + path.join(localDirectory, ".claude", "settings.local.json"), + ...(localDirectory === cwd ? [] : [path.join(cwd, ".claude", "settings.local.json")]), + path.join(cwd, ".claude", "settings.json"), + ]), + path.join(homePath, "settings.json"), + ]; + for (const settingsFile of settingsFiles) { + const value = yield* readToolSearchSetting(settingsFile); + if (value !== undefined) return claudeToolSearchOverride(value); + } + return claudeToolSearchOverride(adapterOptions.environment.ENABLE_TOOL_SEARCH); + }, + ); + const userInvocableSkills = (cwd: string | null) => discoverClaudeSkills( adapterOptions.settings, @@ -4525,7 +4577,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); - // Foreground Bash calls whose output file is being tailed, by tool_use_id. const taskOutputTails = new Map< string, { readonly fiber: Fiber.Fiber; readonly stop: Deferred.Deferred } @@ -4543,8 +4594,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); const sdkMessageLock = yield* Semaphore.make(1); - // Survives turn settle: members mostly finish after the launching - // turn has ended, and each transition must be emitted exactly once. + // Members can finish after the launching turn; retain state to emit each transition once. const workflowMemberStates = yield* Ref.make( new Map< string, @@ -4554,15 +4604,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly usage: OrchestrationV2SubagentUsage | undefined; readonly transcriptReadAttempts: number; readonly finalTranscriptRead: boolean; - // Where the next read of the member's transcript starts; every - // line before it is already projected into the member's thread. readonly agentId: string | undefined; readonly transcriptCursor: WorkflowAgentTranscriptCursor; readonly nextItemOrdinal: number; readonly toolCalls: Map; readonly carriedModelCallId: string | undefined; readonly textShown: boolean; - // The transcript reached the end of the member's last turn. readonly turnEnded: boolean; readonly resultShown: boolean; readonly result: string | undefined; @@ -4622,9 +4669,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); - // Claude can settle a turn while a local_bash background task keeps running; the CLI - // later re-invokes the model (a "wake turn") on the same query stream with no active - // provider turn. + // Background local_bash tasks can trigger wake turns on the query stream with no active provider turn. const lastTurnRouteByNativeThread = yield* Ref.make( new Map< string, @@ -4634,6 +4679,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); + // Survives roster REPLACE levels, which rebuild every entry without the agent's mark. + const awaitedBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); // Wake eligibility is separate from the Waiting roster. It survives empty // background_tasks_changed levels (SDK: empty level can precede task_notification) and // is consumed when the first idle notification is buffered/offered so duplicates cannot @@ -4649,15 +4698,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); const goalsByNativeThread = new Map(); const goalCheckedTurns = new Set(); - // Subagent registry that survives turn settle: a background subagent - // (Agent with run_in_background) can complete after the root turn - // ended, and its task_notification must both count as wake evidence - // and hydrate the original subagent node instead of being dropped. + // Background subagents can finish after root settlement; retain their registry for wake evidence and hydration. const sessionSubagentsByTaskId = yield* Ref.make(new Map()); const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); - // Requests that move a running subagent to the background before a - // steer, by the tool call that started its run. Claude may never - // answer one, so a later steer waits on it rather than asking again. + // Claude may never answer a background move; later steers must wait on the same request. const backgroundMovesByToolUseId = new Map>(); const subagentToolCalls = new Map(); // A root carrier's latest item while its message_delta is pending, by @@ -4674,12 +4718,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( string, Deferred.Deferred >(); - // An idle outcome stays buffered for the parent's wake, but must not - // settle the child again after that child resumes. + // Buffered idle outcomes must not settle a resumed child again. const appliedSubagentOutcomes = new WeakSet(); - // Subagent frames can precede the task_started that registers their - // subagent (the same race rememberPendingClaudeSubagentLaunch covers). - // They wait here and replay once task_started registers the owner. + // Subagent frames can precede the task_started that registers their owner. const pendingSubagentFramesByToolUseId = yield* Ref.make( new Map>(), ); @@ -4689,10 +4730,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( { readonly messages: ReadonlyArray; readonly detail: string | null } >(), ); - // Claude's wake result carries no task id, and its wake can run after a prompt the user - // queued meanwhile, so reports survive one user turn and then expire: a notification - // Claude folded into its own turn cannot name a later wake. One Ref holds both, so a - // report cannot be stamped with a turn that has already been superseded. + // Wake results lack task ids and can follow queued prompts; retain reports for one user turn. + // Keep report stamps atomic with turn changes. const wakeReportsByNativeThread = yield* Ref.make<{ readonly userTurns: ReadonlyMap; readonly reports: ReadonlyMap< @@ -4701,8 +4740,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; }>({ userTurns: new Map(), reports: new Map() }); const backgroundedSubagentTaskIds = yield* Ref.make>(new Set()); - // Subagents another subagent started (spawn_depth above 1). Claude - // reports their end to the owning subagent, so it never wakes the root. + // Nested subagent completions notify their owner and never wake the root. const nestedSubagentTaskIds = yield* Ref.make>(new Set()); const isNestedSubagentTask = (taskId: string) => Ref.get(nestedSubagentTaskIds).pipe(Effect.map((ids) => ids.has(taskId))); @@ -4743,8 +4781,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const clearWakeReports = (nativeThreadId: string) => takeWakeReports(nativeThreadId).pipe(Effect.asVoid); - // An empty roster level can land before the task's notification, and - // the wake still needs to name the task. + // Empty roster levels can precede notifications; retain tasks for wake attribution. const lastKnownOpaqueTasks = yield* Ref.make( new Map(), ); @@ -4843,8 +4880,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => Queue.offer(events, event).pipe(Effect.asVoid); - // Reports one root request's usage as the current context. Usage - // without input is a placeholder, never a measurement. + // Usage without input is a placeholder, not a context measurement. const emitContextUsage = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, usage: ClaudeMessageUsage, @@ -4885,8 +4921,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Claude emits retry progress but no recovered frame; the next - // assistant message is the first reliable evidence of recovery. + // Claude has no recovered frame; the next assistant message proves recovery. const completeProviderRetry = Effect.fn("ClaudeAdapterV2.completeProviderRetry")(function* ( context: ActiveClaudeTurnContext, updatedAt: DateTime.Utc, @@ -5077,12 +5112,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), input.nativeThreadId, ); + const awaitedTaskIds = taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + input.nativeThreadId, + ); const now = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { ...input.providerThread, providerSessionId: session.id, ...(input.status === undefined ? {} : { status: input.status }), - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster, awaitedTaskIds), ...(goalsByNativeThread.has(input.nativeThreadId) ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null } : {}), @@ -5164,6 +5203,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( tasks.map((task) => task.taskId), ); } + const listed = new Set(tasks.map((task) => task.taskId)); + yield* Ref.update(awaitedBackgroundTasksByNativeThread, (current) => { + const awaited = current.get(nativeThreadId); + if (awaited === undefined) return current; + const kept = new Set([...awaited].filter((taskId) => listed.has(taskId))); + if (kept.size === awaited.size) return current; + const updated = new Map(current); + if (kept.size === 0) updated.delete(nativeThreadId); + else updated.set(nativeThreadId, kept); + return updated; + }); }); const upsertPendingBackgroundTask = @@ -5196,7 +5246,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.set(nativeThreadId, nextRoster); } return [true, updated] as const; - }); + }).pipe( + Effect.tap(() => + clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ), + ), + ); const clearPendingBackgroundTasksForNativeThread = /* Provider events include streaming deltas; avoid a span for each update. */ @@ -5209,6 +5267,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); + yield* clearNativeThreadTaskIdSet(awaitedBackgroundTasksByNativeThread, nativeThreadId); yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); @@ -5234,8 +5293,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // Process-scoped level: SDK emits nothing at CLI start, so both the Waiting roster and - // wake eligibility reset when a live query opens or is replaced for this native thread. + // The SDK emits no initial roster; reset roster and wake eligibility when opening a live query. const resetBackgroundTaskStateForNativeThreadProcess = Effect.fnUntraced(function* ( nativeThreadId: string, options?: { @@ -5353,7 +5411,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const buildToolCallArtifacts = (input: { - // Only a call in a run (runId set) needs its provider turn. readonly context: ActiveClaudeTurnContext | null; readonly nativeItemId: string; readonly toolName: string; @@ -5599,9 +5656,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - // The resume's task_started names only the SendMessage call, while the resumed run's - // frames carry the launch call as parent_tool_use_id, which only the CLI's session - // storage still records. + // Resume frames reference the launch call, but task_started names SendMessage; recover the mapping from CLI storage. const recoverResumedClaudeSubagent = Effect.fnUntraced(function* (resume: { readonly context: ActiveClaudeTurnContext; readonly nativeThreadId: string; @@ -5814,8 +5869,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (input.coordinator.task.status === "running" ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3 : restarted || previous?.finalTranscriptRead !== true); - // The CLI forwards nothing a member says, so every frame while it - // runs reads what its transcript gained since the last one. const readLiveTranscript = status === "running" && transcriptDir !== undefined && agentId !== undefined; if (!changed && !readTranscript && !readLiveTranscript) continue; @@ -5954,8 +6007,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolCalls.clear(); }); if (restarted) yield* endOpenToolCalls(); - // A retry may rewrite the transcript in place, past the old cursor; - // re-reading from 0 re-emits only items already shown. + // Retries can rewrite transcripts past the cursor; rereading from zero repeats only already shown items. let transcriptCursor = previous === undefined || restarted || previous.agentId !== agentId ? initialWorkflowAgentTranscriptCursor @@ -5965,17 +6017,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( let turnEnded = !restarted && previous?.turnEnded === true; let transcriptRead = false; let lastReadAt = now; - // Text before a tool call is not the reply, so wait for the turn's end. + // Pre-tool text is not the reply; wait for turn end. const awaitingReply = () => settled && transcriptRead && !turnEnded && !resultShown; - // Milliseconds until a read started at `at` sees every line the - // member wrote before it settled. const flushWindowLeft = (at: DateTime.Utc) => completedAt === null ? 0 : WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS - (DateTime.toEpochMillis(at) - DateTime.toEpochMillis(completedAt)); - // Each read is capped, so keep reading until the transcript has no - // more complete lines; a settled member's answer may be past the cap. + // Drain capped reads; settled replies can lie beyond the cap. let transcriptDrained = true; if ( (readTranscript || readLiveTranscript) && @@ -5996,7 +6045,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), }), }).pipe(Effect.option); - // False once the coordinator was replaced mid-read. const drain = Effect.fnUntraced(function* () { lastReadAt = yield* DateTime.now; transcriptDrained = false; @@ -6010,7 +6058,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( transcriptCursor = read.value.cursor; for (const line of read.value.lines) { if (yield* projectSubagentTranscriptLine(target, line)) textShown = true; - // A later line (a retry, or a resumed member) reopens the turn. turnEnded = isClaudeTranscriptTurnEnd(line); } // A line longer than the cap can't advance; stop rather than spin. @@ -6019,8 +6066,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return true; }); if (!(yield* drain())) return; - // The coordinator sends nothing after its last frame, so wait out - // the flush window here rather than settle on the excerpt. + // The coordinator sends no more frames; wait out the transcript flush before using the excerpt. const waitLeft = flushWindowLeft(lastReadAt); if ( input.coordinator.task.status !== "running" && @@ -6033,11 +6079,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } if (settled) { - // As for an ordinary subagent, a call still open once the member - // settles will never get its result. + // Open calls receive no results after their member settles. yield* endOpenToolCalls(); - // Only the progress excerpt is left when the transcript says - // nothing, read after its last writes had time to land. if ( !textShown && !resultShown && @@ -6173,11 +6216,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task.runId !== input.onlyIfRunningInRun) ) return; - // Status is monotone with one exception: task_started for a known - // task id is an authoritative CLI lifecycle event (SendMessage to a - // completed subagent resumes it and re-emits task_started with the - // same id), so it may re-open a terminal entry. A late or - // out-of-order task_progress must not. + // Only task_started may reopen a terminal task id on resume; late task_progress must not. const isReopen = input.reopen === true && existingSubagent !== undefined && @@ -6191,9 +6230,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return; } - // A task_started under a tool call other than the current run's is - // a resume: SendMessage re-emits task_started for the same task id - // under its own tool_use_id, with the sent message as the prompt. + // Resume task_started uses the SendMessage tool_use_id and sent message as its prompt. const resumeToolUseId = input.reopen === true && existingSubagent !== undefined && @@ -6331,13 +6368,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null), usageTally: existingSubagent?.usageTally ?? makeClaudeSubagentUsageTally(), } satisfies ActiveClaudeSubagent; - // The same terminal protection, applied atomically: a concurrent - // fiber (live stream vs continuation drain) may have terminalized - // the registry entry after this update's lookup read it. A resume - // re-open (task_started) bypasses it only when the registered entry - // is still the terminal generation the lookup resolved; if a - // concurrent fiber installed a newer terminal entry meanwhile, the - // re-open must not clobber its result. + // Ingestion can terminalize after lookup; reopen only that generation to preserve newer results. const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { const registered = current.get(input.taskId); if ( @@ -6438,8 +6469,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }, }); } - // Ahead of the child thread's records: the run a resume attributes - // the subagent to adopts its child thread from this event. + // Emit before child records so the resumed run can adopt the child thread. yield* emitProviderEvent({ type: "subagent.updated", driver: CLAUDE_PROVIDER, @@ -6482,9 +6512,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( rootNodeId: childRootNodeId, role: "user", text: task.prompt, - // A workflow's prompt is its script; clients open the thread with - // the workflow's plan instead and keep the script collapsed. A - // brief says it is not one, so its text is never taken for one. + // Workflow prompts are collapsed scripts; clients open with the plan. A brief must not be mistaken for a script. ...(existingSubagent === undefined ? { workflowScript: task.workflow !== undefined } : {}), @@ -7003,11 +7031,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - /** - * Projects one transcript line of a subagent whose messages the CLI - * does not forward (a workflow member) the way a forwarded frame with - * its parent_tool_use_id is projected. Returns whether it carried text. - */ const projectSubagentTranscriptLine = Effect.fnUntraced(function* ( target: ClaudeSubagentProjectionTarget, line: unknown, @@ -7313,6 +7336,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.context.compaction?.status === "running") { + const reason = input.result?.subtype === "success" ? input.result.result.trim() : ""; yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -7323,6 +7347,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.status === "completed" || input.status === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(reason === "" ? {} : { summary: reason }), completedAt: input.completedAt, updatedAt: input.completedAt, }, @@ -7339,8 +7364,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) continue; if (liveQuery !== null && !liveQuery.stopping) { - // Best effort and off the terminal path: an unanswered stop must - // not keep the turn from ending. + // An unanswered stop must not delay turn settlement. yield* liveQuery.query .stopTask(taskId) .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope)); @@ -7509,8 +7533,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), }, }), - // Surface this native thread's roster before the root turn terminals so - // writeFinalRunEvents preserves it. + // Emit the roster before root settlement so writeFinalRunEvents preserves it. Effect.gen(function* () { const nativeThreadId = input.context.input.providerThread.nativeThreadRef?.nativeId ?? null; @@ -7534,6 +7557,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), nativeThreadId, ); + const awaitedTaskIds = + nativeThreadId === null + ? new Set() + : taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + nativeThreadId, + ); const clearConversationHead = input.status === "completed" && input.context.input.providerThread.nativeConversationHeadRef !== null; @@ -7571,7 +7601,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.input.providerThread.firstRunOrdinal ?? input.context.input.runOrdinal, lastRunOrdinal: input.context.input.runOrdinal, - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster( + roster, + awaitedTaskIds, + ), status: input.status === "completed" ? "active" : "idle", updatedAt: input.completedAt, }; @@ -7869,9 +7902,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( entry.subtype === "task_started" && entry.task_id === message.task_id, )); - // A subagent launched while the root is idle (inside a native wake - // turn) must reach the continuation drain with its task_started, or - // its frames have no registered owner and are held indefinitely. + // Idle-wake task_started must reach the continuation drain before child frames can find their owner. const isNewSubagentTaskStarted = message.type === "system" && message.subtype === "task_started" && @@ -7966,10 +7997,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.task_id, ); } - // Buffer it for replay, but do not open an opaque-task continuation until the turn's - // `init`, or native user, assistant, or result output, proves that Claude actually - // began the wake turn. Only root frames prove it: a background subagent keeps - // streaming its own frames while the root is idle. + // Open continuations only after root init, user, assistant, or result frames prove a wake began. + // Background child frames can stream while the root is idle. const buffered = (yield* Ref.get(wakeBuffers)).get(wakeInput.nativeThreadId); const hasBufferedNotification = buffered?.messages.some( @@ -8026,6 +8055,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); + // Untraced like the roster message handling: it runs for every streamed roster change. + const emitRosterUpdate = Effect.fnUntraced(function* ( + nativeThreadId: string, + activeContext: ActiveClaudeTurnContext | null, + ) { + const baseThread = + activeContext?.input.providerThread ?? + (yield* Ref.get(lastProviderThreadByNativeThread)).get(nativeThreadId); + if (baseThread === undefined) { + return; + } + const status = + activeContext === null + ? ("idle" as const) + : baseThread.status === "idle" + ? ("active" as const) + : baseThread.status; + yield* emitProviderThreadRoster({ nativeThreadId, providerThread: baseThread, status }); + }); + const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -8099,24 +8148,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; } - const baseThread = - input.activeContext?.input.providerThread ?? - (yield* Ref.get(lastProviderThreadByNativeThread)).get(input.nativeThreadId); - if (baseThread === undefined) { - return true; - } - - const status = - input.activeContext === null - ? ("idle" as const) - : baseThread.status === "idle" - ? ("active" as const) - : baseThread.status; - yield* emitProviderThreadRoster({ - nativeThreadId: input.nativeThreadId, - providerThread: baseThread, - status, - }); + yield* emitRosterUpdate(input.nativeThreadId, input.activeContext); return true; }); @@ -8195,10 +8227,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const message = input.message; - // Before any routing: a Monitor started during an idle wake turn - // reports its task before the drain replays the tool call. + // Track before routing: idle-wake Monitors report tasks before replaying their tool calls. yield* trackClaudeMonitorCalls(message); - // Before routing too: the wake gate reads it while the root is idle. + // Update before routing because the idle wake gate reads this. if ( message.type === "system" && message.subtype === "task_started" && @@ -8332,8 +8363,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { yield* applyWorkflowProgressWithoutTurn(message); - // task_notification must buffer wake evidence while still tracked - // on the roster; clearing first would drop the wake pin. + // Buffer wake evidence before clearing the task from the roster. if (message.type === "system" && message.subtype === "task_notification") { yield* bufferWakeMessage({ nativeThreadId: liveQuery.nativeThreadId, message }); yield* applyBackgroundTaskRosterMessage({ @@ -8341,7 +8371,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message, activeContext: null, }); - // Record wake evidence first, while the child is still running. const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id); const settled = settledTurnByNativeThread.get(liveQuery.nativeThreadId); const hasBufferedStart = @@ -8463,8 +8492,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( !context.reasoning.snapshots.has(message.uuid) ) { context.reasoning.snapshots.add(message.uuid); - // The SDK emits one assistant snapshot per completed content block. - // Count thinking blocks separately so snapshots share the streamed ID. + // The SDK snapshots each completed block; count thinking blocks separately to preserve streamed ids. for (const block of message.message.content) { if (block.type !== "thinking") { const update = context.reasoning.lastThinking; @@ -8562,6 +8590,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : message.compact_result === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(message.compact_result === "failed" && message.compact_error + ? { summary: message.compact_error } + : {}), completedAt: now, updatedAt: now, }; @@ -8762,8 +8793,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (message.type === "assistant" && input.replayed !== true) { const now = yield* DateTime.now; yield* completeProviderRetry(context, now); - // `` frames (usage limits, sign-in errors, /goal status) - // are generated by the CLI and report zero usage, not the context. + // CLI-generated frames report zero usage, not context usage. if ( message.parent_tool_use_id === null && message.message.usage !== undefined && @@ -8794,8 +8824,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const parentToolUseId = message.parent_tool_use_id; const snapshotModel = typeof message.message.model === "string" ? message.message.model.trim() : ""; - // Claude's own notices (a usage limit, an API error) carry - // "", which says nothing about the subagent's model. + // "" identifies CLI notices, not the subagent model. const model = snapshotModel.length === 0 || snapshotModel === "" ? undefined @@ -8916,8 +8945,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( launch?.ownerToolUseId === undefined ? undefined : yield* resolveSubagentByToolUseId(context, launch.ownerToolUseId); - // With no model of its own, a nested subagent runs on its - // owner's (the SDK's default for a subagent's Agent call). + // Nested subagents default to their owner's model. const model = launch?.model ?? owner?.task.model ?? undefined; if ( message.is_backgrounded === true && @@ -9200,18 +9228,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( status: "running", }); } - // A resume task_started reuses the resuming tool call's - // tool_use_id (e.g. SendMessage), whose tool_result only - // acknowledges delivery. Only the Agent launch's tool_result may - // terminalize the subagent, and Agent tool_uses never enter - // toolCalls (they project as subagent rows instead). + // Resume tool results only acknowledge delivery; only Agent launch results may settle subagents. + // Agent tool_uses project as subagent rows and never enter toolCalls. if ( subagent !== undefined && findToolCall(context, toolResult.tool_use_id) === undefined ) { - // A background Agent launch resolves its tool_use immediately - // with an async-launch ACK while the task keeps running; only - // the eventual task_notification terminalizes the subagent. + // Background Agent launches ACK immediately; only task_notification settles them. if ( !isClaudeToolResultError(toolResult) && (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output)) @@ -9379,6 +9402,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } + // Claude Code echoes /compact's stdout ("Not enough messages to compact.") as a + // synthetic assistant message. The compaction item reports that outcome; it is not a + // reply. + if ( + message.type === "assistant" && + "local_command_source" in message && + isCompactionCommandMessage({ role: "user", ...context.input.message }) + ) { + return; + } if (assistantText !== null && assistantText.text.length > 0) { yield* emitAssistantTextArtifacts({ context, @@ -9430,6 +9463,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : resultTextFromSdkMessage(message); if ( + // A /compact's result text reports the compaction ("Not enough messages to + // compact."), which its compaction item already carries; it is not a reply. + !( + message.type === "result" && + message.subtype === "success" && + message.local_command === "compact" + ) && context.assistant.emittedNativeItemIds.size === 0 && context.assistant.fallbackText.length === 0 && resultText !== null && @@ -9463,8 +9503,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (message.terminal_reason == null || message.terminal_reason === "api_error" || message.terminal_reason === "blocking_limit"); - // The CLI also labels a 403 authentication_failed. Logging in again - // can't grant a permission, so show the CLI's own text instead. + // The CLI also labels 403 authentication_failed; show its text because login cannot grant access. const permissionDenied = message.subtype === "success" && message.api_error_status === 403; const failureHint = permissionDenied @@ -9473,9 +9512,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (usageLimited ? "Claude usage limit reached. Send the message again once the limit resets." : undefined)); - // A /compact the usage limit stopped still ends in a success - // result. Failing the turn lets the usage-limit guard hold queued - // messages instead of sending them into the same limit (#19327). + // Usage-limited /compact reports success; fail it so queued messages wait for the limit to clear. const limitedCompaction = usageLimited && message.subtype === "success" && message.local_command === "compact" ? context.compactionFailure @@ -9629,8 +9666,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (lifecycle.state === "started") { context.promptStarted = true; } - // Claude finishes a prompt whose uuid its transcript already - // holds without starting it, and no turn will ever answer it. + // Previously recorded prompt uuids complete without starting a turn. if (lifecycle.state === "completed" && !context.promptStarted) { yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", { providerTurnId: context.providerTurnId, @@ -9693,9 +9729,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } return; } - // Its output already streamed into this turn, so only its result - // stays out: settling here would end the turn before its prompt - // runs. The prompt's own turn ends it with an echoing result. + // Foreign output already streamed; exclude its result so only the prompt's own echoing result settles the turn. if (isClaudeTaskNotificationOriginResult(message)) { yield* clearWakeReports(liveQuery.nativeThreadId); } @@ -9760,11 +9794,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName !== "ExitPlanMode" && (toolName === "AskUserQuestion" || requiresClaudeApproval(context)) ) { - // The SDK blocks on the answer, and the prompt echo cannot arrive - // until the held turn goes on, so the request is raised now and - // the held output goes with it to the pending prompt turn, as it - // did before this turn was held. ExitPlanMode is answered at once - // below, so its frames stay held. + // The SDK blocks prompt echoes on permission answers; release held output except for ExitPlanMode. yield* releaseHeldRootFrames(context); if (toolName !== "Agent") { yield* ensureToolCallStarted({ @@ -10003,7 +10033,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( question, options: [ { - label: "Compact and continue", + label: CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER, description: "Resume with a summary and use fewer tokens.", }, { @@ -10037,7 +10067,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { behavior: "completed" as const, result: - selection === "Compact and continue" + selection === CLAUDE_RESUME_COMPACTION_COMPACT_ANSWER ? ("compact" as const) : selection === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER ? ("never" as const) @@ -10096,8 +10126,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); - // A continuation prompts nothing: it drains output the live process - // already produced, so it keeps that process whatever its selection. + // Continuations drain existing output without prompting; retain the live process regardless of selection. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -10156,9 +10185,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return existing; } - // Background agents and shells run inside the CLI process, so a new selection would - // kill them and lose their results. Refuse until they finish or the user presses Stop, - // which closes the process. + // Selection changes kill CLI background work; refuse until it finishes or Stop closes the process. if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -10187,9 +10214,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); - // openedNativeThreads is per session instance and is lost when the - // provider session is idle-released. A prior turn on this native id - // requires resume; sessionId would fail with "already in use". + // Idle release loses openedNativeThreads; persisted turns still require resume to avoid "already in use". const hasPersistedProviderTurn = turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const transcriptMissing = (yield* Ref.get(missingNativeThreads)).has(nativeThreadId); @@ -10207,6 +10232,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, }) : undefined; + const toolSearchOverride = yield* resolveToolSearchOverride(turnInput.runtimePolicy.cwd); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, nativeThreadId, @@ -10243,6 +10269,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( supportedDialogKinds: ["resume_return"], ...(subagentHooks === undefined ? {} : { hooks: subagentHooks }), ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }), + ...(toolSearchOverride === undefined ? {} : { toolSearchOverride }), }); const querySession = yield* queryRunner .open({ @@ -10262,9 +10289,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : Effect.void, ), ); - // Marked only after a successful open: a failed create must not - // leave the runtime believing the native session exists, or the - // retry would resume a session that was never created. + // Mark only successful opens; retries must not resume sessions that failed to create. yield* Ref.update(openedNativeThreads, (current) => { if (current.has(nativeThreadId)) { return current; @@ -10410,10 +10435,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const nativeTurnId = `turn:${turnInput.attemptId}`; - // Fresh for every offer: Claude acks a prompt whose uuid its - // transcript already holds without running a turn, and a session - // can outlive this database (or be resumed from a copy of it), so - // a uuid derived from run ids can repeat. + // Use fresh uuids: native sessions can outlive this database, and Claude skips previously recorded prompts. const promptUuid = isClaudeProviderContinuationTurn(turnInput) ? null : yield* crypto.randomUUIDv4().pipe( @@ -10623,8 +10645,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); return; } - // Transcript reads suspend replay. Hold live SDK frames until the whole - // buffer has been applied so starts, completions and the root result stay ordered. + // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. yield* sdkMessageLock.withPermits(1)( Effect.gen(function* () { if ( @@ -10648,12 +10669,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* finalizeActiveTurn({ context, status: "completed", completedAt }); return; } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. When Claude dequeues - // several notifications at once it can end an empty turn and open - // the next within a millisecond; that next turn is still running - // and owns this run, so the empty turn's result must not settle it. + // Replay results last to avoid rebuffering later frames. An empty turn's result must not settle a newer running turn. const lastResultIndex = drained.findLastIndex((entry) => entry.type === "result"); const tailResult = drained[lastResultIndex]; const emptyTurnBeforeNextTurn = @@ -10851,10 +10867,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); - // A steer is sent with priority "now", which cancels the root turn's - // running tool calls. A subagent the root launched in the foreground is - // one of them, so it moves to the background first and keeps running; - // its result arrives as a task notification like any background agent. + // Move subagents to the background before priority "now" steers cancel foreground tools. const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, deadline: number, @@ -11029,10 +11042,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); yield* Effect.addFinalizer(() => closeSession()); - // A CLI stopped before it saved the first prompt can leave a transcript - // with only metadata lines. Claude can neither resume that id nor - // create it again, so resumeThread fails and the caller binds a fresh - // native session with a context handoff. + // Metadata-only transcripts cannot be resumed or recreated; fail resumeThread to trigger a fresh session and handoff. const hasEmptyClaudeTranscript = Effect.fn("ClaudeAdapterV2.hasEmptyClaudeTranscript")( function* (nativeThreadId: string) { const configuredDir = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; @@ -11042,8 +11052,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : path.join(yield* HostProcess.HomeDirectory, ".claude"), "projects", ); - // Empty only when every copy of the id is empty; any message means - // the CLI may still resume it. + // Any message in any transcript copy means Claude may still resume it. let found = false; for (const project of yield* fileSystem.readDirectory(projectsDir)) { const transcriptPath = path.join(projectsDir, project, `${nativeThreadId}.jsonl`); @@ -11152,7 +11161,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId !== null && // A live process holds the conversation even before saving it. (yield* Ref.get(queryContext))?.nativeThreadId !== nativeThreadId && - // An unreadable transcript leaves the resume to the CLI, as before. + // An unreadable transcript leaves the resume to the CLI. (yield* hasEmptyClaudeTranscript(nativeThreadId).pipe( Effect.orElseSucceed(() => false), )) @@ -11219,6 +11228,40 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); }), + awaitBackgroundTasks: Effect.fn("ClaudeAdapterV2.awaitBackgroundTasks")( + function* ({ providerThread, taskIds, awaited }) { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId == null) return { matched: [] }; + const roster = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ); + const matched = [...new Set(taskIds)].filter((taskId) => roster.has(taskId)); + if (matched.length === 0) return { matched }; + if (awaited) { + yield* addTaskIdsToNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + matched, + ); + } else { + for (const taskId of matched) { + yield* clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ); + } + } + const active = yield* Ref.get(activeTurn); + yield* emitRosterUpdate( + nativeThreadId, + active?.nativeThreadId === nativeThreadId ? active : null, + ); + return { matched }; + }, + (effect) => sdkMessageLock.withPermits(1)(effect), + ), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); @@ -11577,39 +11620,3 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< defaultConfig: (): ClaudeSettings => DEFAULT_CLAUDE_SETTINGS, create: (input) => createClaudeAdapterV2(input, {}), }; - -const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const crypto = yield* Crypto.Crypto; - const hostEnvironment = yield* HostProcess.Environment; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const queryRunner = yield* ClaudeAgentSdkQueryRunner; - const serverConfig = yield* ServerConfig.ServerConfig; - const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; - - return yield* makeClaudeAdapterV2({ - instanceId: CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: hostEnvironment, - attachmentsDir: serverConfig.attachmentsDir, - fileSystem, - path, - crypto, - idAllocator, - queryRunner, - continuationRequests, - }); -}); - -const layer: Layer.Layer< - ProviderAdapter.ProviderAdapterV2, - never, - | ClaudeAgentSdkQueryRunner - | Crypto.Crypto - | FileSystem.FileSystem - | IdAllocator.IdAllocatorV2 - | McpProviderSessions.McpProviderSessions - | Path.Path - | ServerConfig.ServerConfig -> = Layer.effect(ProviderAdapter.ProviderAdapterV2, makeDefaultClaudeAdapterV2()); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 9a4a7ba3cc7af..2ba8f78c451b5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -136,9 +136,7 @@ const openFakeCli = (cli: string) => }); describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => { - // The CLI stops the MCP servers it started, but not their children: a - // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The child - // holds a FIFO open, so its death reads as end-of-file here, with no polling. + // The leftover child holds a FIFO open; its death produces EOF without polling. it.effect("stops what the CLI left running when the session closes", () => Effect.gen(function* () { const cli = fakeClaudeCli( @@ -187,7 +185,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro ); // The CLI keeps running its turn after its pipes close, so a server that - // crashed would leave it working next to the session it resumes (#23370). + // crashed would leave it working next to the session it resumes. it.effect("stops the CLI when the server that started it goes away", () => Effect.gen(function* () { const cli = fakeClaudeCli(['dir=$(dirname "$0")', 'sleep 600 > "$dir/alive"'].join("\n")); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 2a06d25c5030d..87992bab89169 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -12,7 +12,6 @@ import { tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; const SESSION_ID = "0c91bb28-9e89-4889-bd22-3de9880233d3"; const TASK_ID = "bk6xreeye"; -// Claude Code's layout: ///tasks/.output. const makeTaskFile = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -21,8 +20,7 @@ const makeTaskFile = Effect.gen(function* () { return { root, tasks, file: path.join(tasks, `${TASK_ID}.output`) }; }); -// Live clock and a real file: this proves the tail follows the writer, which -// TestClock cannot interleave with filesystem IO. +// TestClock cannot interleave with real filesystem I/O; use the live clock. describe("tailClaudeTaskOutput", () => { it.live("follows a file that appears after the task starts, across split characters", () => Effect.scoped( @@ -39,7 +37,7 @@ describe("tailClaudeTaskOutput", () => { }).pipe(Effect.forkScoped); yield* fileSystem.makeDirectory(tasks, { recursive: true }); - // "é" is two bytes; the first write ends between them. + // Split the two-byte é across writes. const encoded = new TextEncoder().encode("café 1\n"); yield* fileSystem.writeFile(file, encoded.subarray(0, 4)); assert.equal(yield* Queue.take(chunks), "caf"); @@ -97,7 +95,6 @@ describe("tailClaudeTaskOutput", () => { ), }).pipe(Effect.forkScoped); yield* Deferred.await(first); - // Written right before the command exits, then the result arrives. yield* fileSystem.writeFileString(file, "one\ntwo\n"); yield* Deferred.succeed(stop, undefined); yield* Fiber.join(fiber); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts index fbe759e973a40..6ab23fddbe16c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.ts @@ -4,22 +4,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -/** - * Live output for a foreground Claude Bash call. - * - * The Agent SDK sends no stdout until the tool result. Claude Code does write - * each running command's combined output to - * `/claude-///tasks/.output`, naming - * the task in its `task_started` frame (`task_type: "local_bash"`). This path is - * Claude Code's private layout, not SDK API, so every step degrades to "no live - * output": the row still gets its full output from the tool result. - */ +/** The SDK withholds stdout until tool results; tail task_started files for live output. + * The private Claude layout can change; failures omit live output and retain the final result. */ -/** How often the file is checked while its command runs. */ const CLAUDE_TASK_OUTPUT_POLL_MS = 250; -/** Give up looking for a file Claude Code never wrote (a newer layout, or Windows). */ +/** Bound discovery when Claude never writes the file. */ const MAX_LOCATE_ATTEMPTS = 40; -/** Bytes read per poll. A faster writer skips ahead: only the tail is shown anyway. */ +/** Skip ahead of fast writers; only the tail is displayed. */ const MAX_READ_BYTES = 256 * 1024; /** Claude Code's per-user temp root, or none where it has no uid (Windows). */ @@ -56,12 +47,7 @@ const locate = Effect.fn("ClaudeTaskOutputTail.locate")(function* (input: { return Option.none(); }); -/** - * Reads appended output until `stop` completes, then reads once more so the - * last bytes written before the command exited still reach live viewers. The - * caller completes `stop` when the tool result arrives or the turn ends, and - * awaits the returned effect. - */ +/** Complete stop on tool result or turn end, then await the effect; it performs a final read before returning. */ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(function* (input: { readonly root: string; readonly sessionId: string; @@ -70,7 +56,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct readonly onChunk: (chunk: string) => Effect.Effect; }) { const fileSystem = yield* FileSystem.FileSystem; - // True once stopped: the next read is the last. const pause = Effect.raceFirst( Effect.sleep(CLAUDE_TASK_OUTPUT_POLL_MS).pipe(Effect.as(false)), Deferred.await(input.stop).pipe(Effect.as(true)), @@ -82,8 +67,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct if (attempt + 1 >= MAX_LOCATE_ATTEMPTS || (yield* pause)) return; } const filePath = file.value; - // `stream: true` keeps a multi-byte character split across reads intact; - // invalid bytes become U+FFFD. let decoder = new TextDecoder("utf-8", { fatal: false }); let offset = 0; let last = yield* Deferred.isDone(input.stop); @@ -105,7 +88,7 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct if (Option.isSome(bytes)) { // Skipped output must not splice two unrelated lines together. const skipped = start > offset; - // Bytes held from before the gap belong to a character we never finished reading. + // Reset partial UTF-8 bytes when skipping output. if (skipped) decoder = new TextDecoder("utf-8", { fatal: false }); offset = start + bytes.value.byteLength; const text = (skipped ? "\n" : "") + decoder.decode(bytes.value, { stream: true }); @@ -113,7 +96,6 @@ export const tailClaudeTaskOutput = Effect.fn("ClaudeTaskOutputTail.tail")(funct } } if (last) { - // A command that ended mid-character still shows a replacement character. const rest = decoder.decode(); if (rest.length > 0) yield* input.onChunk(rest); return; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index cccf8f8054f9c..3ed879f0f13ef 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -135,6 +135,40 @@ describe("CodexAdapterV2 assistant message streaming", () => { }); describe("CodexAdapterV2 runtime policy", () => { + it.effect.each([undefined, false, true])( + "selects native collaboration modes without prompt overrides with hasT3Mcp=%s", + (hasT3Mcp) => + Effect.gen(function* () { + for (const interactionMode of ["plan", "default"] as const) { + for (const reasoningEffort of [undefined, "high"] as const) { + const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-mode-switch", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode, cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + ...(reasoningEffort === undefined + ? {} + : { options: [{ id: "reasoningEffort", value: reasoningEffort }] }), + }, + ...(hasT3Mcp === undefined ? {} : { hasT3Mcp }), + }); + + assert.deepEqual(params.collaborationMode, { + mode: interactionMode, + settings: { + model: "gpt-5.4", + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + }, + }); + assert.equal(params.effort, reasoningEffort); + assert.equal(params.additionalContext !== undefined, hasT3Mcp === true); + } + } + }), + ); + it.effect("derives concrete Codex turn policies from every T3 runtime mode", () => Effect.gen(function* () { const build = ( @@ -621,6 +655,7 @@ function codexReplayPreamble(input: { approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" }, summary: "detailed", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, }, }, }, @@ -738,8 +773,7 @@ describe("CodexAdapterV2 session initialize", () => { Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); const releaseInitialize = yield* Deferred.make(); - // The transcript allows exactly one handshake: a second `initialize` - // frame fails the replay, as Codex rejects it with "Already initialized". + // Codex rejects a second initialize; the replay permits one handshake. const session = yield* openReplaySession( makeCodexReplayTranscript({ scenario: "concurrent-initialize", @@ -1518,6 +1552,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { model: "gpt-5.4", ...turnPolicies[turnModes[index]!], summary: "detailed", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, }, }, }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 04a6add704815..68256334d17e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -104,10 +104,7 @@ import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachme import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import { - buildCodexAdditionalContext, - buildCodexDeveloperInstructions, -} from "../../provider/CodexDeveloperInstructions.ts"; +import { buildCodexAdditionalContext } from "../../provider/CodexDeveloperInstructions.ts"; import { describeMcpElicitation, toMcpElicitationResponse, @@ -187,8 +184,7 @@ function codexFileChangeApprovalPrompt(input: { return input.grantRoot?.trim() || undefined; } -// Reasoning effort changes future generation, not the existing context's tokenizer -// or model window. All other option changes remain untrusted until new telemetry. +// Reasoning-effort changes preserve the tokenizer and context window; other changes need fresh telemetry. export function canReuseCodexContextUsage(previous: ModelSelection, next: ModelSelection): boolean { return modelSelectionsEqual( { @@ -228,8 +224,7 @@ export function codexSubagentUsage( } const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); const CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS = 50; -// Each plan flush stores the whole plan three times (node, plan, turn item), so -// flush at the cadence clients already see for assistant text. +// Plan flushes store three copies; use the client's assistant-text cadence. const CODEX_PLAN_DELTA_FLUSH_INTERVAL_MS = 400; const CodexBackgroundTerminalTerminateResponse = Schema.Struct({ terminated: Schema.Boolean, @@ -552,8 +547,7 @@ function codexMcpToolOutput( : { error: item.error.message, result: resultOutput }; } -// Reading an app's resource is one MCP round trip; a server that hangs must not -// hold the item (and the run's ingestion) open. +// A hung MCP resource read must not hold run ingestion open. const MCP_APP_CAPTURE_TIMEOUT = "20 seconds"; function codexMcpAppResourceUri(item: CodexDynamicToolItem): string | undefined { @@ -870,13 +864,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const developerInstructions = - input.hasT3Mcp !== true - ? undefined - : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode); - // An app's context is text an MCP server wrote, so it goes in as untrusted - // context: Codex renders it as quoted user-side input, never as developer - // instructions. + // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ entry.key, @@ -897,38 +885,28 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur t3Context === undefined && Object.keys(appContext).length === 0 ? undefined : { ...t3Context, ...appContext }; - const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = - input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined - ? undefined - : { - mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "default", - settings: { - model: input.modelSelection.model, - // Codex falls back to the model's default effort when this is left out. - ...(effort === undefined ? {} : { reasoning_effort: effort }), - ...(developerInstructions === undefined - ? {} - : { developer_instructions: developerInstructions }), - }, - }; - return yield* decodeCodexTurnStartParamsWithCollaborationMode({ threadId: input.nativeThreadId, input: input.codexInput, ...(additionalContext ? { additionalContext } : {}), cwd: input.runtimePolicy.cwd, model: input.modelSelection.model, - // Model catalogues can default summaries to "none". Request them on every - // turn, including resumed threads, for T3's reasoning timeline. + // Catalogues can default summaries to "none"; request them explicitly on every turn. summary: "detailed", - // Always explicit: omitting this on resume leaves Codex's previous - // reviewer sticky after switching away from Auto mode. + // Omitting the reviewer on resume preserves the previous Auto reviewer. approvalsReviewer: runtimeModeDefaults.approvalsReviewer, ...(approvalPolicy === undefined ? {} : { approvalPolicy }), ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), - ...(collaborationMode === undefined ? {} : { collaborationMode }), + // Codex supplies its own mode instructions; send both modes to clear Plan on exit. + collaborationMode: { + mode: input.runtimePolicy.interactionMode, + settings: { + model: input.modelSelection.model, + ...(effort === undefined ? {} : { reasoning_effort: effort }), + }, + }, }); }, ); @@ -1006,8 +984,7 @@ function providerThreadFromCodexThread(input: { }; } -// Counts toward native rollback. `/goal` control turns never reach Codex and -// carry no native ref, so they must not consume a native turn on revert. +// /goal control turns never reach Codex and must not count toward native rollback. const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.nativeTurnRef !== null && (turn.status === "completed" || @@ -1115,11 +1092,7 @@ const resolveCodexForkRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveForkR }, ); -/** - * `lastTurnId` is inclusive on the Codex side, so a fork requested at the - * selected turn omits every later turn atomically. That matters for paginated - * threads. - */ +/** Codex lastTurnId is inclusive, atomically omitting later turns even in paginated threads. */ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBoundary")(function* ( input: ProviderAdapter.ProviderAdapterV2ForkThreadInput, ) { @@ -1140,10 +1113,7 @@ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBou return { lastTurnId: nativeTurnId, rollbackTurnCount: 0 }; }); -/** - * The generated `thread/read` response schema does not surface `historyMode`, - * so the probe goes through the raw request channel with a permissive decode. - */ +/** Generated thread/read schemas omit historyMode; probe through the raw request channel. */ const CodexThreadHistoryMetadata = Schema.Struct({ thread: Schema.Struct({ historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), @@ -1659,18 +1629,13 @@ export class CodexAppServerClientFactory extends Context.Service< CodexAppServerClientFactoryShape >()("t3/orchestration-v2/Adapters/CodexAdapterV2/CodexAppServerClientFactory") {} -/** - * Codex waits this long for MCP servers before the first model request and - * leaves out any server still starting (default 1 s, too short for cold - * `npx`/`uvx` servers). - */ +/** MCP startup grace in milliseconds; the 1 s default excludes cold npx/uvx servers from the first model request. */ export const CODEX_THREAD_CONFIG = { "tools.update_plan.enabled": true, mcp_optional_startup_grace_ms: 10_000, } as const; -// T3 wait tools can block for up to an hour. Keep Codex's MCP timeout above -// that limit so the server's own wait timeout ends a long call. +// T3 waits can block for an hour; keep Codex's MCP timeout above that. const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60; function codexThreadRuntimeParams(input: { @@ -2041,10 +2006,7 @@ export interface CodexAdapterV2Options { readonly fileSystem: FileSystem.FileSystem; readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly serverConfig: ServerConfig["Service"]; - /** - * Optional: adapters that omit it keep projection-only handling for late item - * completions. - */ + /** Omitting this leaves late completions projection-only. */ readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -2132,8 +2094,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); const initialized = yield* Ref.make(false); - // Threads share this app-server, and Codex rejects a second - // `initialize`. + // Codex rejects a second initialize on the shared app-server. const initializePermit = yield* Semaphore.make(1); const ensureInitialized = initializePermit.withPermit( Effect.gen(function* () { @@ -2149,8 +2110,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }) .pipe( Effect.catchTags({ - // A caller interrupted after its `initialize` reached Codex - // leaves the app-server initialized but the flag unset. + // Interruption can leave Codex initialized before the local flag is set. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage === "Already initialized" ? Effect.void @@ -2221,19 +2181,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const pendingRuntimeRequests = yield* Ref.make( new Map(), ); - /** - * Codex's own acceptForSession only skips the exact command or patch it - * answered, so every new command would ask again. - */ + /** Codex acceptForSession covers only the exact command or patch, not future commands. */ const sessionApprovals = new Map< string, { readonly runtimeMode: RuntimeMode; readonly kinds: Set } >(); - /** - * The allowed kinds for an approval request's turn. Read it when the - * request arrives and add to that same set when the user answers, so a - * late answer from a turn whose mode has since ended is dropped. - */ + /** Bind answers to the request's approval set so mode changes invalidate late answers. */ const endSessionApprovalsOnModeChange = (context: ActiveCodexTurnContext) => { const nativeThreadId = context.providerThread.nativeThreadRef?.nativeId; if ( @@ -2256,11 +2209,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( sessionApprovals.set(nativeThreadId, { runtimeMode, kinds }); return kinds; }; - /** - * Turn contexts retained past turn/completed while background command - * items started in that turn are still running, so late item events - * keep projecting instead of being dropped. - */ + /** Retain settled contexts while background commands run so late item events still project. */ const settledTurns = yield* Ref.make(new Map()); const runningCommandItemsByTurn = yield* Ref.make( new Map>(), @@ -2270,22 +2219,19 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ); const interruptingNativeTurns = yield* Ref.make(new Set()); const terminalizedNonCompletedNativeTurns = yield* Ref.make(new Set()); - // Keep the run event stream open until descendant provider state is - // terminal, otherwise the root terminal can strand child projections. + // Keep the stream open until descendants settle to avoid stranding child projections. const deferredRootTerminals = yield* Ref.make(new Map()); const offeredContinuationItemsByTurn = yield* Ref.make(new Map>()); const finalAnswerItemIdsByTurn = yield* Ref.make(new Map>()); const completedFinalAnswerTextsByTurn = yield* Ref.make(new Map>()); - // Native completion and the interrupt timeout share one finalization - // path. Serialize the race so only one can publish terminal events. + // Serialize native completion and interrupt timeout so only one publishes terminal events. const turnTerminalizationPermit = yield* Semaphore.make(1); const goalsByNativeThread = new Map(); const rootProviderThreads = new Map(); const goalHolds = new Map(); const goalRuns = new Map>(); const goalActivations = new Map(); - // Stops in progress by native thread. Until a Stop resolves its target, - // a held run must not settle as completed. + // Held runs must not complete while Stop resolves its target. const goalStops = new Map(); const latestGoalTurnId = (providerTurnId: ProviderTurnId) => goalRuns.get(providerTurnId)?.at(-1)?.providerTurnId ?? providerTurnId; @@ -2316,8 +2262,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* emitGoalUpdate(nativeThreadId); }); - // Call only for new model-output activity. A local item/completed can - // arrive while the upstream response stream is still retrying. + // Only new model output proves recovery; local item/completed can arrive during upstream retries. const completeProviderRetry = Effect.fn("CodexAdapterV2.completeProviderRetry")(function* ( context: ActiveCodexTurnContext, updatedAt: DateTime.Utc, @@ -2683,9 +2628,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( Effect.catchTags({ CodexAppServerProcessExitedError: () => Effect.succeed({ terminated: true }), CodexAppServerInputStreamEndedError: () => Effect.succeed({ terminated: true }), - // The thread is unloaded, as Codex does a minute after a - // settle or archive unsubscribes it. Unloading kills the - // thread's terminals, so nothing is left to stop. + // Unloading kills terminals, so "thread not found" means nothing remains to stop. CodexAppServerRequestError: (error) => error.code === -32600 && error.errorMessage.startsWith("thread not found:") ? Effect.succeed({ terminated: true }) @@ -2831,8 +2774,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const now = yield* DateTime.now; const prior = input.subagent.task; const settled = !isOrchestrationV2WorkActive(prior.status); - // Snapshots can arrive after completion. Only an actual new turn - // may reopen the reusable child identity. + // Late snapshots must not reopen completed children; only new turns may. if (settled && isOrchestrationV2WorkActive(input.status) && !input.reopen) return; const status = settled && !input.reopen ? prior.status : input.status; const completedAt = isOrchestrationV2WorkActive(status) @@ -3667,7 +3609,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return { node, message, turnItem }; }); - // Reuse the text coalescer so token bursts do not create one database write per token. + // Coalesce token bursts to avoid a database write per token. const reasoningParts = new Map< string, { turnId: string; nativeItemId: string; stream: "summary" | "content"; index: number } @@ -3732,7 +3674,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (context === undefined || payload.delta.length === 0) return; yield* completeProviderRetry(context, yield* DateTime.now); const itemId = reasoningPartKey(payload.turnId, payload.itemId, stream, index); - // Reserve the position before the delayed flush, ahead of later tool items. + // Reserve positions before delayed flushes so later tools stay after them. yield* resolveItemOrdinal(context, itemId); yield* reasoningDeltas.append({ turnId: payload.turnId, itemId, delta: payload.delta }); }); @@ -4182,10 +4124,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( item: Extract, resourceUri: string, ) => - // Only reading and storing the document can be cancelled; a write - // cut short removes its file. Once a document is stored, the capture - // either lands it on the item or, when its turn already settled the - // item, removes it, so no stored app goes unreferenced. + // Only reads and storage are cancellable; remove partial writes. Once stored, attach the app or delete it if the item settled. Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const reference = @@ -4211,10 +4150,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), Effect.provideService(FileSystem.FileSystem, fileSystem), - // Every failure and defect becomes a plain tool row, so - // a crashed read never holds the turn open; only - // interruption, the turn cancelling the capture, ends - // the fiber here. + // Convert failures and defects to plain tool rows; only interruption ends the capture fiber. Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => @@ -4228,8 +4164,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) : undefined; const artifacts = yield* buildDynamicToolArtifacts(context, item); - // Cleared before the final emit: ingestion re-checks pending work - // after each event, and must see this capture done by then. + // Clear before emitting; ingestion rechecks pending work after each event. if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) { if (reference !== undefined) { const filePath = resolveAttachmentPathById({ @@ -4684,8 +4619,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/commandExecution/outputDelta", (payload) => Effect.gen(function* () { - // A delta always follows its item/started, so the turn is already - // known; background commands keep printing after it settles. + // Background commands emit deltas after settlement; item/started already registered their turn. const context = (yield* Ref.get(activeTurns)).get(payload.turnId) ?? (yield* Ref.get(settledTurns)).get(payload.turnId); @@ -4907,9 +4841,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Deferred.succeed(goalHold.next, next); return; } - // A goal turn with no run to own it (Codex continued after the run - // settled, or raced a Stop): stop it and pause the goal rather than - // work out of sight. + // Pause orphan goal turns so they cannot work without an owning run. if ( rootProviderThreads.has(payload.threadId) && (goalsByNativeThread.get(payload.threadId)?.status === "active" || @@ -5268,8 +5200,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/completed", (payload) => Effect.gen(function* () { if (payload.item.type === "contextCompaction") - // The notification callback runs on the input reader. Let it read - // the injection response while the session-scoped request waits. + // Fork requests off the notification reader so it can read their responses. yield* restoreAdditionalContext(payload.threadId).pipe( Effect.forkIn(scope, { startImmediately: true }), ); @@ -6208,10 +6139,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const hasActiveDescendants = Array.from((yield* Ref.get(activeTurns)).values()).some( (candidate) => isDescendantCodexTurn(candidate, input.context), ); - // Native subagents run on their own threads, so a failed root leaves - // them working. Holding the root until they finish would keep the - // run open with no turn to steer. Stop interrupts the whole lineage, - // so an interrupted root still waits for its descendants to settle. + // Failed roots leave native children working and must settle without them. + // Interrupted roots wait for descendants because Stop interrupts the whole lineage. if (event.status === "failed" && hasActiveDescendants) { yield* emitRootTerminal(input.context, { ...event, childTurnsContinue: true }); return; @@ -6248,10 +6177,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }, ); - /** - * Call with the terminalization permit, after removing the hold from - * `goalHolds`. - */ + /** Hold the terminalization permit and remove the goalHolds entry before calling. */ const settleGoalHold = Effect.fnUntraced(function* ( hold: CodexGoalHold, status: "completed" | "interrupted", @@ -6471,11 +6397,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( status: input.status, completedAt: input.completedAt, }); - // Codex does not wake a parent whose turn already completed, so - // the child's result would sit unread until the next message. - // Only direct children of a root turn wake it: a grandchild - // reports to its own parent, and a root turn already running on - // the thread receives the result natively. + // Codex does not wake a completed parent turn, leaving child results unread. + // Only direct children wake an idle root turn; grandchildren report to their + // own parent, and running root turns receive results natively. const parent = input.context.subagent.parentContext; if ( continuationRequests !== undefined && @@ -6550,9 +6474,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( updated.set(input.nativeTurnId, input.context); return updated; }); - // Background work that finished between the check above and - // the turn being recorded as settled saw no settled turn to - // release, so it is re-checked once recorded. + // Recheck after recording settlement; background work may have finished before a settled turn existed to release. yield* releaseSettledTurnIfIdle(input.nativeTurnId); } yield* Ref.update(activeTurns, (current) => { @@ -6730,12 +6652,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : undefined; }; - /** - * Codex can end or continue the turn while the pause is in flight, so - * the target resolves after it: a held run settles here and leaves only - * its retained work to stop, and a continued run moves Stop to its - * newest turn. - */ + /** Resolve Stop after pausing; Codex can settle or continue to a newer turn while pause is in flight. */ const resolveGoalStopTarget = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput, ) { @@ -6955,7 +6872,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } if (command.type === "set") { if (current !== null) yield* client.request("thread/goal/clear", { threadId }); - // Paused until our turn runs, so Codex does not start one first. + // Keep paused until our turn starts so Codex cannot start one first. const { goal } = yield* client.request("thread/goal/set", { threadId, objective: command.objective, @@ -6963,8 +6880,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }); goalsByNativeThread.set(threadId, providerGoalFromCodex(goal)); } - // Until the goal is active, a fast first turn still holds the run, - // and Stop cancels the activation. + // Hold fast first turns until activation; Stop cancels activation. const activation = { stopped: false }; goalActivations.set(threadId, activation); yield* Effect.gen(function* () { @@ -7093,10 +7009,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( providerSession: session, events: Stream.fromEffectRepeat(Queue.take(events)), canReuseContextUsage: canReuseCodexContextUsage, - // Known gap: a subagent that Codex resumes later reads as completed - // (not pending) between turns, so idle release can win the race - // against a long-delayed resume. Codex emits no resume-expected - // signal to pin on. + // Idle release can beat delayed subagent resumes: Codex reports them completed between turns with no resume-expected signal. hasPendingBackgroundWork: Effect.gen(function* () { for (const items of (yield* Ref.get(runningCommandItemsByTurn)).values()) { if (items.size > 0) { @@ -7245,8 +7158,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Ref.update(pendingRootTurns, (current) => new Map(current).set(threadId, turnInput), ); - // Cleared on interrupt too, as startTurn does: an interrupted start - // must not adopt the native turn that a late turn/started reports. + // Clear on interruption; a late turn/started must not be adopted after an interrupted start. yield* client.request("thread/compact/start", { threadId }).pipe( Effect.onError(() => Ref.update(pendingRootTurns, (current) => { @@ -7281,9 +7193,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }) .pipe( Effect.as(true), - // Older app servers reject unknown methods before mutating history. - // Transport errors and invalid payloads are ambiguous and must not - // fall through to a second delivery in the current user message. + // Retry only unknown-method errors, which precede history mutation. Transport or payload errors leave delivery ambiguous. Effect.catchTags({ CodexAppServerRequestError: (error) => error.code === -32601 ? Effect.succeed(false) : Effect.fail(error), @@ -7623,8 +7533,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* Effect.gen(function* () { for (const target of interruptTargets) { const context = target.context; - // A null start timestamp acknowledges a queued turn; Codex cannot interrupt it - // until turn/started confirms that the native task exists. + // Null start timestamps ACK queued turns; interruption must wait for turn/started. if (context.nativeStartReady !== undefined) { const ready = yield* Deferred.await(context.nativeStartReady).pipe( Effect.timeoutOption("10 seconds"), @@ -7917,9 +7826,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( cause: `Cannot roll back Codex thread ${threadId}: the thread uses legacy history, which Codex 0.156 cannot revert.`, }); } - // `thread/revert` only acts on a thread loaded in this app-server - // process. After a restart or idle release, load it the same way - // the next turn would before reverting. + // thread/revert requires the thread loaded in this app-server; resume after restart or idle release. if (!loaded) { yield* client.raw.request("thread/resume", { threadId, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index f7cc4a1043d5c..7ef7a9819b2d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -528,8 +528,7 @@ export function layer( readonly assertCompleteOnFinalize?: boolean; }, ) { - // Skill discovery also scans user roots under HOME; an empty HOME keeps - // replays from picking up the host's own skills. + // Empty HOME prevents skill discovery from using the host's skills. const layerHostEnvironment = Layer.effect( HostProcess.Environment, Effect.gen(function* () { @@ -729,8 +728,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( model: cursorSdkModelSelection(input.modelSelection), mode: interactionMode === "plan" ? "plan" : "agent", } as const; - // Agents sometimes search the workspace's parent too; map it to /tmp so the - // recording host's temp layout stays out of the fixture. + // Agents search parent directories too; normalize the recording host's temp layout. const replacements: ReadonlyArray = [ [input.cwd, input.transcriptCwd ?? `/tmp/cursor-replay-${input.scenario}`], [input.cwd.slice(0, input.cwd.lastIndexOf("/")), "/tmp"], @@ -772,10 +770,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( } }; - // In a mid-tool interrupt recording, frames after the first tool-call-started - // are held until run.cancel is recorded, so the cancel directly follows its - // trigger. Holding appends rather than waits: updates can arrive while send - // is still flushing them, and blocking there would never return. + // Hold frames after tool-call-started until run.cancel. Append without waiting because updates can arrive during send. let awaitingToolStart = input.interruptAfterToolStart === true; let heldUntilCancel: Array | undefined; @@ -854,10 +849,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( } if (input.interruptAfterToolStart === true) { yield* awaitSignal(toolStarted, "Cursor SDK tool-call-started before interrupt"); - // Cancelling synchronously from the SDK's tool-call-started callback - // leaves an unhandled AbortError inside @cursor/sdk that kills the - // process (reproduced on 1.0.22, 1.0.31, and 1.0.32). Cancelling from a - // later timer was clean in the same probes; 10 ms is that deferral. + // Synchronous tool-call-started cancellation crashes @cursor/sdk with AbortError (1.0.22, 1.0.31, 1.0.32); defer 10 ms. yield* Effect.sleep("10 millis"); const cancelling = yield* run.cancel.pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.succeed(cancelSent, undefined); diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 1f39d3a402299..aeedce64deb47 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -200,12 +200,7 @@ interface MuseReplayHost { open: boolean; } -/** - * A write that matches a later outbound record of the same turn is held until - * the transcript reaches it, because independent adapter fibers can reach stdin - * in a different order than recorded. A write that matches nothing before the - * next turn fails at once. - */ +/** Adapter fibers can reorder stdin writes; hold matches within the turn until reached, and fail unmatched writes. */ class MuseReplayController { private cursor = 0; private failure: unknown = null; @@ -426,7 +421,6 @@ export function layer(input: { settings: DEFAULT_MUSE_SETTINGS, environment: input.environment ?? {}, createHost: input.createHost, - // Same queue the continuation worker drains when the fixture runs it. continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests, }), ]; @@ -442,11 +436,7 @@ export function layer(input: { ); } -/** - * Fills a recording's `` with what the adapter sends on this host: - * the canonical path (macOS /var -> /private/var), which `turn/start` names in - * museWorkspaceRoot's form (verbatim on Windows). - */ +/** Uses canonical workspace paths, with turn/start in museWorkspaceRoot form (verbatim on Windows). */ export const materializeMuseReplayWorkspace = Effect.fn("materializeMuseReplayWorkspace")( function* (transcript: ProviderReplayTranscript, workspace: string) { const canonical = yield* FileSystem.FileSystem.pipe( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 15ca24556ad61..ca0288ab2c5e6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1946,8 +1946,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("reads the agents again while a fresh server still lists none", () => Effect.gen(function* () { - // 2.0.18 answers `/api/agent` with `[]` for a moment after it starts; - // rules written from that would drop the agents' own paths. + // OpenCode 2.0.18 briefly returns no agents on startup; using that response drops agent paths. const runtime = yield* openCode2ReplayRuntime([ ...opening, out("agent.list", ""), @@ -1984,7 +1983,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { out("message.list", { sessionID: SESSION, order: "desc", limit: "50" }), reply("message.list", { data: [ - // OpenCode appends an `idle` item after each execution it ends. ...(ended === undefined ? [] : [{ id: "msg_idle_gap", time: { created: 3 }, type: "idle", outcome: ended }]), @@ -2108,8 +2106,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("finishes a workspace command whose stream dropped before its inbox event", () => Effect.gen(function* () { - // `session.command` answers 204 without the inbox item's id, and the - // stream drops before `session.inbox.enqueued` would have named it. + // session.command returns 204 without an inbox id; the stream drops before session.inbox.enqueued provides it. const { runtime, thread } = yield* resumed([ out("command.list", ""), reply("command.list", { location: { directory: WORK }, data: [{ name: "hello" }] }), @@ -3183,9 +3180,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { status: 500, body: { _tag: "UnknownError", message: "disk I/O error" }, }), - // Uncleared, OpenCode would commit the stage on the next prompt. The - // clear wakes the session into an empty execution of its own, with no - // turn of T3's running: it is no subagent's follow-up. + // Uncleared stages commit on the next prompt; clearing triggers an empty execution without a T3 turn. out("session.revert.clear", { sessionID: SESSION }), reply("session.revert.clear", null), event("session.revert.cleared", { sessionID: SESSION }), @@ -3233,11 +3228,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { it.effect("prompts under ids no other session on the server can hold", () => Effect.gen(function* () { - // Two T3 databases on one external server repeat thread ids and run - // ordinals, so their turns can share an attempt id and their steers a - // message id. OpenCode refuses a prompt id another session already - // holds with 409 ConflictError; the replay refuses a client id it - // already gave another recorded message. + // Separate databases can repeat prompt ids on one server; OpenCode rejects cross-session duplicates with 409. const OTHER = "ses_f148ca2deffeOtherSession000"; const steerInto = (session: string, recorded: string): ReadonlyArray => [ out("session.prompt", { @@ -3383,7 +3374,6 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ); yield* runtime.startTurn(turnInput(thread)); yield* runtime.startTurn(turnInput(other)); - // The second turn starts and the first ends while the session stays running. assert.deepEqual( [...(yield* Fiber.join(collected))].flatMap((event) => event.type === "provider_session.updated" ? [event.providerSession.status] : [], diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index 0b29a15d9e9b1..fd868921f7442 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -149,8 +149,7 @@ const replayHttpClient = ( query, raw === undefined ? undefined : decodeJson(raw), ); - // The event stream and requests are separate connections: a request - // is matched only once what was recorded before it was delivered. + // HTTP streams and requests use separate connections; deliver prior recorded inbound data before matching requests. if (operation.type !== "event.subscribe") await controller.untilInboundDelivered(); if (operation.type === "session.prompt") onPrompt?.(operation.input); await controller.expectOutbound(operation); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 08e4fc9066ab1..13ee0a8a97744 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -232,11 +232,7 @@ export class OpenCodeReplayController { } } - /** - * Like {@link untilEventsDelivered}, but also waits for recorded responses to - * be taken: over HTTP a request can start while an earlier one's answer is - * still in flight, and must not be matched against that answer. - */ + /** Waits for events and responses to be consumed so concurrent HTTP requests cannot match earlier answers. */ async untilInboundDelivered(): Promise { while (true) { this.throwFailure(); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index 0a1ebab3c5a30..0ca1005f10494 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -119,11 +119,7 @@ function adapterRequestId(frame: unknown): string | undefined { return typeof id === "string" && ADAPTER_REQUEST_ID.test(id) ? id : undefined; } -/** - * Compares a recorded request against the adapter's own request id, so the - * correlation Pi recorded holds without pinning counter values. Any other id, - * such as the Pi request id an `extension_ui_response` must echo, stays exact. - */ +/** Rebind adapter request ids without pinning counters; provider ids that responses must echo stay exact. */ function withRequestId(expected: unknown, actual: unknown): unknown { const actualId = adapterRequestId(actual); return isRecord(expected) && adapterRequestId(expected) !== undefined && actualId !== undefined @@ -181,13 +177,7 @@ interface PiReplayProcess { open: boolean; } -/** - * Independent adapter fibers (skill discovery beside thread setup, a settle - * probe beside a steer) can reach stdin in a different order than recorded, so - * a write that matches a later outbound record of the same turn is held until - * the transcript reaches it. A write that matches nothing before the next - * turn-starting record fails immediately. - */ +/** Adapter fibers can reorder stdin writes; hold matches within the turn until reached, and fail unmatched writes. */ class PiReplayController { private cursor = 0; private failure: unknown = null; diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts index 2bb43e53ca6bc..1653e5b42f1d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -82,11 +82,7 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { }; } -/** - * A background launch answers the model with an acknowledgement rather than the run's output, and - * that acknowledgement is the only place the run's filesystem handles appear — nothing later on - * the event stream repeats them. - */ +/** The background-launch ACK is the only frame carrying the run's filesystem handles. */ export function parseClaudeWorkflowRunHandles( toolUseResult: unknown, ): OrchestrationV2WorkflowRunHandles | undefined { @@ -119,12 +115,7 @@ function parseClaudeWorkflowProgress(message: unknown): { return phases.length === 0 && agents.length === 0 ? null : { phases, agents }; } -/** - * Returns undefined when nothing identifies the task as a workflow. Progress frames do not repeat - * `task_type`, so this is the single place that decides whether a task is a coordinator; without - * it every ordinary subagent's usage frame would mint an empty workflow and render as a - * coordinator. - */ +/** Returns undefined without workflow evidence; progress frames omit task_type and ordinary subagents also emit usage. */ export function mergeClaudeWorkflowProgress(input: { readonly previous: OrchestrationV2SubagentWorkflow | undefined; readonly name?: string | undefined; diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts index 324ff6b1fb8d2..de128992d743d 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.test.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.test.ts @@ -177,9 +177,7 @@ describe("AttachmentClaims", () => { const copyArgs = yield* Deferred.make(); const copyDone = yield* Deferred.make(); - // fs.copyFile cannot be cancelled: the write runs on a separate fiber - // while the claim's copyFile only observes its completion, so the file - // still lands after the awaiting effect is interrupted. + // fs.copyFile cannot be cancelled; its separate writer must finish after the awaiting effect is interrupted. const gatedFileSystem = Object.create(fileSystem, { copyFile: { value: (from: string, to: string) => diff --git a/apps/server/src/orchestration-v2/AttachmentClaims.ts b/apps/server/src/orchestration-v2/AttachmentClaims.ts index 40955e74a12f8..e20d18014bf5c 100644 --- a/apps/server/src/orchestration-v2/AttachmentClaims.ts +++ b/apps/server/src/orchestration-v2/AttachmentClaims.ts @@ -51,10 +51,7 @@ export const releaseClaimedAttachments = Effect.fn("AttachmentClaims.releaseClai }, ); -/** - * A copy, not a move — the pending file stays behind as the retry source for a - * failed bootstrap, and the periodic pending sweep reclaims it later. - */ +/** Copies preserve the pending retry source after failed bootstrap; the pending sweep reclaims it. */ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingAttachments")( function* (input: { readonly threadId: string; @@ -120,11 +117,8 @@ export const claimPendingAttachments = Effect.fn("AttachmentClaims.claimPendingA message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`, }); } - // A copy, not a hard link: an agent editing the delivered file in - // place must not mutate the retry source. fs.copyFile cannot be - // cancelled, so the copy and its rollback registration stay in one - // uninterruptible region: an interrupt landing mid-copy still waits - // for the write to settle and records the path before cleanup runs. + // Copies isolate edits from the retry source. Keep the uncancellable copy and rollback registration + // uninterruptible so cleanup always sees completed writes. yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe( Effect.mapError( (cause) => diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 45bf03610316d..3727df9e3753b 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -67,12 +67,10 @@ export const layer: Layer.Layer< }) { const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } = yield* projections.getCheckpointCaptureContext(input.threadId, input); - // A stopped run is already terminal. Its checkpoint is the rollback point - // for the message after it, so capture leaves its status alone. + // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; - // The effect is at-least-once. A settled run with a checkpoint proves - // that an earlier execution committed its result. + // A checkpoint on a settled run proves this at-least-once effect already committed. if ( run !== undefined && run.checkpointId !== null && @@ -80,9 +78,7 @@ export const layer: Layer.Layer< ) { return; } - // Rollback shares this effect lane, so it can only land before a capture - // runs, e.g. while a failed capture waits to retry. The workspace now - // holds the rollback target, and the run must stay discarded. + // Rollback uses the same lane and can precede a capture retry; preserve the restored workspace and discarded run. if (run?.status === "rolled_back") { return; } @@ -128,10 +124,7 @@ export const layer: Layer.Layer< appRunOrdinal: run.ordinal, capturedAt, }); - // Match RunExecutionService: capture loaded the waiting run before - // materializing baselines. Omit delegatedCompletion so a newer cohort - // write during capture is not overwritten by this stale snapshot - // (ProjectionStore preserves the field when absent from the payload). + // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; const commandId = CommandId.make(`command:effect:checkpoint.capture:${run.id}`); yield* eventSink.commitCommand({ diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 1327e17dbdb01..5888feed349a7 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -9,8 +9,7 @@ import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; -// A checkpoint snapshots the whole checkout. Check at command admission and -// again before provider rollback so a newly shared worktree is rejected too. +// Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( function* ( thread: Pick, @@ -57,10 +56,8 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const paths = [ otherThread.worktreePath, ...other.checkpointScopes.map((candidate) => candidate.cwd), - // A failed turn can leave an errored session with a live event stream. - // A session shared across threads keeps the cwd it was opened with, - // often another thread's; each turn runs in the thread's own - // worktree or project root, which this list already covers. + // Errored sessions can still stream. Shared sessions retain another thread's opening cwd; + // turn worktrees and project roots are already covered here. ...providerContext.providerSessions .filter( (session) => diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index 149c82452b605..dadbf7d15ca47 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -173,7 +173,6 @@ it.effect.each(["initial", "waiting"])( it.effect.each([ { order: "cleanup-first", restoreFiles: true }, { order: "rollback-first", restoreFiles: true }, - // A path the server would not have made is left missing, not recreated. { order: "cleanup-first", restoreFiles: false, unmanaged: true }, ])("locks rollback with %s", ({ order, restoreFiles, unmanaged = false }) => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 96a5e10c98927..385de626cfdf8 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -278,9 +278,7 @@ export const layer: Layer.Layer< yield* checkpoints.deleteStaleRefs({ scope, checkpoints: staleCheckpoints }); } - // Thread commands write full provider thread rows under this lock. Without - // it, a message sent during the rollback can plan against the old native - // session and commit after this, undoing the provider's fork. + // Lock against full-row thread writes that could restore the old native session after rollback. yield* threadCommands.withLock( input.threadId, Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index cf425716bcbb8..934805ac30fea 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -158,8 +158,7 @@ it.effect.each(deliveries)( Effect.sync(() => { offers.push(message); nativeQueue.push(message); - // Claude Code 2.1.289's queue watcher aborts when a now-priority - // command arrives, including while Bash blocks pending reads. + // Claude Code 2.1.289 aborts on now-priority commands even while Bash blocks reads. if (nativeQueue.some((command) => command.priority === "now")) { batchAbort.abort({ kind: "interrupt" }); } @@ -383,8 +382,6 @@ it.effect.each(deliveries)( queuedRunId: queuedRun.id, targetRunId: parent.id, }); - // Promotion cancels the queued run and moves its message into the - // running run as a steer that remembers where it came from. const promoted = yield* orchestrator.getThreadProjection(threadId); assert.equal(promoted.runs.find((run) => run.id === queuedRun.id)?.status, "cancelled"); assert.equal( diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts index e3f722d45517d..06475371f4ce4 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts @@ -50,7 +50,6 @@ const modelSelection = { const CODEX_DRIVER = ProviderDriverKind.make("codex"); const ITEM_ID = TurnItemId.make("turn-item:command"); -/** What a client holds after applying frames, using the same shared normalizer. */ function applyFrame( state: TerminalOutputState, frame: OrchestrationV2CommandOutputFrame, @@ -150,7 +149,6 @@ const writeCommandItem = ( }); }); -/** Collect frames into a queue so the test takes them one receipt at a time. */ const subscribeFrames = (threadId: ThreadId) => Effect.gen(function* () { const hub = yield* CommandOutputHub.CommandOutputHub; @@ -181,7 +179,6 @@ layer("CommandOutputHub", (it) => { const hub = yield* CommandOutputHub.CommandOutputHub; const threadId = yield* seedThread; yield* writeCommandItem(threadId, { status: "running" }); - // Output that arrived before anyone looked, ending mid-escape and mid-redraw. yield* hub.append({ threadId, itemId: ITEM_ID, @@ -196,7 +193,6 @@ layer("CommandOutputHub", (it) => { let client = applyFrame(EMPTY_TERMINAL_OUTPUT, first); assert.equal(client.text, "\u001b[0;32mbuilding\u001b[0m\n55%"); - // Chunks that land between frames arrive together in one append. yield* TestClock.adjust("1 second"); yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "1m" }); yield* hub.append({ threadId, itemId: ITEM_ID, chunk: "\r100%\u001b[0m\ndone\n" }); @@ -210,7 +206,6 @@ layer("CommandOutputHub", (it) => { ).text, ); - // The persisted item settles the row; its output is what the client keeps. yield* TestClock.adjust("1 second"); yield* writeCommandItem(threadId, { status: "completed", @@ -251,7 +246,6 @@ layer("CommandOutputHub", (it) => { const frames = yield* subscribeFrames(threadId); let client = applyFrame(EMPTY_TERMINAL_OUTPUT, yield* takeFrame(frames)); assert.equal(client.text, "abc"); - // 64 newer commands push this one's tail out of the hub. for (let index = 0; index < 64; index += 1) { yield* hub.append({ threadId, @@ -353,8 +347,7 @@ layer("CommandOutputHub", (it) => { const frames = yield* subscribeFrames(threadId); yield* takeFrame(frames); yield* TestClock.adjust("1 second"); - // Some providers stop a command without reporting its output. The session - // manager releases the live tail before the settled item is persisted. + // Some providers omit stopped-command output; the live tail is released before the settled item persists. yield* hub.finish({ threadId, itemId: ITEM_ID }); yield* writeCommandItem(threadId, { status: "interrupted" }); const final = yield* takeFrame(frames); diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.ts b/apps/server/src/orchestration-v2/CommandOutputHub.ts index 57b4b855cd9b8..c3f97eff335a8 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.ts @@ -26,15 +26,7 @@ import * as Stream from "effect/Stream"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -/** - * Live command output. - * - * Output never becomes an orchestration event. Adapters push raw chunks here - * (through the session manager), the hub keeps a bounded, normalized tail per - * running command, and `subscribe` serves one expanded row: the current tail, - * then coalesced appends, then the persisted final output. Memory is bounded - * per command and in count, and a client that is not showing the row receives nothing. - */ +/** Output bypasses orchestration events; expanded rows receive tails, coalesced appends, then persisted output. */ /** Fastest a subscriber receives frames while output flows. */ const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; @@ -42,16 +34,14 @@ const COMMAND_OUTPUT_FRAME_INTERVAL_MS = 100; const COMMAND_OUTPUT_REPLACE_INTERVAL_MS = 500; /** Raw text a subscriber may accumulate before it is sent the tail instead. */ const COMMAND_OUTPUT_MAX_APPEND_CHARS = 16 * 1024; -/** Running commands tracked at once; the least recently written is dropped beyond this. */ +/** Evict the least recently written command beyond this limit. */ const MAX_LIVE_COMMANDS = 64; -/** Finished tails kept for a settle that reports no output of its own (an interrupt). */ +/** Retain finished tails for settlements that omit output. */ const MAX_FINISHED_COMMANDS = 16; -/** Final output read from persistence is normalized from at most this many trailing chars. */ const MAX_FINAL_SOURCE_CHARS = TERMINAL_OUTPUT_TAIL_CHARS * 4; interface Subscriber { pendingAppend: string; - /** The next frame must carry the whole tail. */ needsReplace: boolean; /** `needsReplace` was caused by the subscriber falling behind. */ overflowed: boolean; @@ -61,14 +51,9 @@ interface Subscriber { export type CommandOutputTarget = OrchestrationV2SubscribeCommandOutputInput; export interface CommandOutputHubShape { - /** Add raw output for a running command. Called for every provider chunk. */ readonly append: (input: CommandOutputTarget & { readonly chunk: string }) => Effect.Effect; - /** - * The command settled. Its tail is kept briefly for a settle that carries no - * output (an interrupt); otherwise the persisted output is what rows show. - */ + /** Retains the tail briefly for settlements without output; otherwise rows use persisted output. */ readonly finish: (input: CommandOutputTarget) => Effect.Effect; - /** Frames for one row until its command settles; see `OrchestrationV2CommandOutputFrame`. */ readonly subscribe: ( input: CommandOutputTarget, ) => Stream.Stream; @@ -84,7 +69,6 @@ function commandKey(target: CommandOutputTarget): string { return `${target.threadId}\u0000${target.itemId}`; } -/** Claude Bash rows stored before #15505 hold the raw `{ stdout, stderr, interrupted, ... }` result. */ const decodeLegacyClaudeBashResult = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ stdout: Schema.String, stderr: Schema.String, interrupted: Schema.Boolean }), @@ -100,7 +84,6 @@ function legacyClaudeBashOutput(output: string): string { return out.length > 0 && err.length > 0 && !out.endsWith("\n") ? `${out}\n${err}` : out + err; } -/** The output a row shows from a persisted item, bounded before normalization. */ function persistedCommandOutput(output: string | undefined): { readonly text: string; readonly truncated: boolean; @@ -108,7 +91,7 @@ function persistedCommandOutput(output: string | undefined): { if (output === undefined) return { text: "", truncated: false }; let source = output; if (output.length > MAX_FINAL_SOURCE_CHARS) { - // Start on a fresh line, so the cut can't land inside an escape sequence. + // Cut at a newline to avoid splitting escape sequences. source = output.slice(output.length - MAX_FINAL_SOURCE_CHARS); const newline = source.indexOf("\n"); if (newline !== -1 && newline + 1 < source.length) source = source.slice(newline + 1); @@ -120,7 +103,6 @@ function persistedCommandOutput(output: string | undefined): { }; } -/** Sets `key` as the newest entry of `map`, dropping the oldest beyond `max`. */ function setBounded( map: Map, key: string, @@ -139,7 +121,6 @@ function setBounded( export const make = Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; const projections = yield* ProjectionStore.ProjectionStoreV2; - // Insertion order doubles as recency: every write re-inserts its entry. const tails = new Map(); const finished = new Map(); const subscribers = new Map>(); @@ -167,7 +148,7 @@ export const make = Effect.gen(function* () { const key = commandKey(target); const tail = appendTerminalOutput(tails.get(key) ?? EMPTY_TERMINAL_OUTPUT, chunk); setBounded(tails, key, tail, MAX_LIVE_COMMANDS, (oldest) => { - // Its viewers no longer share the hub's state; resync them on the next frame. + // Eviction invalidates viewers' state; resync on their next frame. for (const subscriber of subscribers.get(oldest) ?? []) { subscriber.needsReplace = true; subscriber.pendingAppend = ""; @@ -196,7 +177,7 @@ export const make = Effect.gen(function* () { overflowed: false, wake, }; - // Attach before reading anything, so no chunk falls between a read and the attach. + // Subscribe before reading to avoid losing intervening chunks. const keySubscribers = subscribers.get(key) ?? new Set(); keySubscribers.add(subscriber); subscribers.set(key, keySubscribers); @@ -209,8 +190,7 @@ export const make = Effect.gen(function* () { }), ); - // The persisted item says when the command is done and what it finally printed. - // Providers that report output only in item snapshots (ACP, Pi) stream through here too. + // Persisted items provide settlement and final output; snapshot-only providers (ACP, Pi) also stream here. let snapshotOutput: string | undefined; let lost: Cause.Cause | undefined; let settled: Extract | undefined; @@ -240,8 +220,7 @@ export const make = Effect.gen(function* () { ? observe(stored.event.payload) : Effect.void, ), - // Without its event stream the row can never settle: end with an error - // instead of waiting forever. + // A lost event stream cannot settle the row; end with an error. Effect.catchCause((cause) => Effect.sync(() => { lost = cause; @@ -250,7 +229,7 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); const item = yield* projections.getTurnItem(target); - // Only command rows have output; anything else would hold a subscription open forever. + // Non-command rows would hold output subscriptions open forever. if (item === null || item.type !== "command_execution") { return yield* new OrchestrationV2CommandOutputError({ threadId: target.threadId, @@ -280,7 +259,6 @@ export const make = Effect.gen(function* () { } lastFrameAt = yield* Clock.currentTimeMillis; throttle = COMMAND_OUTPUT_FRAME_INTERVAL_MS; - // A finished tail still shows until the settled item arrives. const tail = tails.get(key) ?? finished.get(key); if (settled !== undefined) { if (settled.output === undefined && tail !== undefined) { @@ -302,8 +280,7 @@ export const make = Effect.gen(function* () { if (tail === undefined) { return { kind: "replace", ...persistedCommandOutput(snapshotOutput), running: true }; } - // The cursor, style and any unfinished escape ride along so the client - // applies later appends exactly as the hub does. + // Include terminal state so clients interpret later appends identically. return { kind: "replace", text: terminalOutputResumeText(tail), diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 923542d877dfe..6c07a410a1b36 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -135,8 +135,7 @@ export function resolveMessageDispatchIntent( ): MessageDispatchMode { const mode = resolveDeliveryIntent(projection, requestedMode, deliveryIntent); if (mode.type !== "steer_active") return mode; - // Compaction and sign-out turns cannot take steering input. A message sent - // while one runs waits in the queue like any other follow-up. + // Compaction and sign-out cannot accept steers; queue follow-ups. const target = projection.runs.find((run) => run.id === mode.targetRunId); const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId); return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage) @@ -398,8 +397,7 @@ const ensureContextHandoff: CommandPolicyV2Shape["ensureContextHandoff"] = (inpu }; const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) => { - // Unsuccessful runs may have no native turn or assistant cursor. Forking - // those at native head can include later turns, so use the bounded transcript. + // Unsuccessful runs may lack native cursors; fork bounded transcripts to exclude later turns. const canForkNatively = (input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") && input.sameProvider && diff --git a/apps/server/src/orchestration-v2/CommandReceiptStore.ts b/apps/server/src/orchestration-v2/CommandReceiptStore.ts index 981255484e734..ed8c690e5b727 100644 --- a/apps/server/src/orchestration-v2/CommandReceiptStore.ts +++ b/apps/server/src/orchestration-v2/CommandReceiptStore.ts @@ -71,11 +71,11 @@ export interface CommandReceiptStoreV2Shape { readonly upsert: ( receipt: AnyCommandReceiptV2, ) => Effect.Effect; - /** A thread command's receipt; none when the command id belongs to a project command. */ + /** None for project command ids. */ readonly getByCommandId: ( commandId: CommandId, ) => Effect.Effect, CommandReceiptStoreV2Error>; - /** A project command's receipt; none when the command id belongs to a thread command. */ + /** None for thread command ids. */ readonly getProjectByCommandId: ( commandId: CommandId, ) => Effect.Effect, CommandReceiptStoreV2Error>; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index 799defe965cb8..ff2ca1fbec441 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -12,11 +12,7 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; -/** - * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. - * Run `unsent` when the provider refused the turn before reading the prompt, so that - * refusal does not leave an ambiguous marker behind. - */ +/** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { readonly handoffs: ReadonlyArray; @@ -132,8 +128,7 @@ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextH yield* persist("pending"); } if (input.deferInline) { - // Compaction APIs cannot accept an inline transcript. Keep it available - // for the next ordinary turn when native injection is unsupported. + // Compaction cannot carry inline transcripts; retain for the next turn if native injection is unsupported. yield* Effect.forEach(pending, input.persist, { discard: true }); return { context: "", delivered: Effect.void, unsent: Effect.void }; } diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index ff1d85a14068c..761743c30f449 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2037,7 +2037,7 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { completionWake: "always", continuationPending: true, runStatus: "completed", - lostWork: [{ kind: "monitor", label: "Gate attempt 9" }], + lostWork: [{ kind: "monitor", label: "Gate attempt 9", awaited: true }], now, }); yield* orchestrator.recoverDelegatedTasks; @@ -2059,9 +2059,7 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); - // A running provider turn does not prove the provider consumed the delivery - // (Claude marks the turn running before it reads the prompt), so a cut - // delivery is offered again even when a continuation resumes that turn. + // Running does not prove prompt consumption in Claude; re-offer cut deliveries even when resuming the turn. it.effect("re-offers a cut delivery even when a continuation resumes its turn", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -2339,6 +2337,72 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { }), ); + it.effect( + "recovers an unpublished child failure after its completion delivery was disposed", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:unpublished-failure-parent"); + const projectId = ProjectId.make("project:unpublished-failure-parent"); + const runId = RunId.make("run:unpublished-failure-parent"); + const rootNodeId = NodeId.make("node:unpublished-failure-parent-root"); + yield* seedParentWithTerminalTask({ + threadId, + projectId, + runId, + rootNodeId, + taskId: NodeId.make("node:unpublished-failure-parent-settled"), + deliveryState: "delivered", + now, + }); + const child = yield* seedRestartCancelledChild({ + parentThreadId: threadId, + projectId, + parentRunId: runId, + rootNodeId, + name: "unpublished-failure-child", + completionWake: "always", + continuationPending: false, + now, + }); + // Skip the live terminal listener to model a committed failure whose + // publication was interrupted. task_cancel then disposed its delivery. + yield* eventSink.write({ + commandId: reconcileCommandId("unpublished-failure"), + events: [ + runEvent({ + threadId: child.childThreadId, + runId: child.childRunId, + ordinal: 1, + status: "failed", + now, + }), + ], + }); + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.dispose", + commandId: CommandId.make("command:unpublished-failure-dispose"), + parentThreadId: threadId, + taskId: child.taskId, + }); + yield* orchestrator.recoverDelegatedTasks; + const recovered = yield* orchestrator.getThreadProjection(threadId); + const task = recovered.subagents.find((row) => row.id === child.taskId); + assert.equal(task?.status, "failed"); + assert.equal(task?.result, "Child task ended with status failed."); + assert.equal(task?.completionDelivery?.state, "disposed"); + assert.lengthOf(recovered.runs, 1); + assert.lengthOf( + recovered.contextTransfers.filter((transfer) => transfer.type === "subagent_result"), + 1, + ); + yield* orchestrator.recoverDelegatedTasks; + assert.deepEqual(yield* orchestrator.getThreadProjection(threadId), recovered); + }), + ); + it.effect("wakes the parent for a settled_only task once its spawning turn ended", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/DispatchModeLimit.ts b/apps/server/src/orchestration-v2/DispatchModeLimit.ts index ed255570d8ad5..8d7c8797b6e19 100644 --- a/apps/server/src/orchestration-v2/DispatchModeLimit.ts +++ b/apps/server/src/orchestration-v2/DispatchModeLimit.ts @@ -12,11 +12,7 @@ export interface DispatchModeRefusal extends DispatchModes { readonly mode: "runtime" | "interaction"; } -/** - * A caller checks its target's modes before dispatching, but the target's user can raise them - * meanwhile; the orchestrator re-checks this limit inside the thread's command lock, where nothing - * else can change them. - */ +/** Recheck target modes under the command lock; the user can raise them after preflight. */ export interface DispatchModeLimitValue extends DispatchModes { readonly refused?: Ref.Ref; } diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index 4ca3d1b224629..507506b1146f8 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -154,12 +154,7 @@ export const OrchestrationEffectStatusV2 = Schema.Literals([ ]); export type OrchestrationEffectStatusV2 = typeof OrchestrationEffectStatusV2.Type; -/** - * Command retries are deduplicated by their receipts first, and a run's effects - * are re-enqueued only around one server restart, so a week is far longer than - * either and leaves recent history for debugging. Failed rows are kept: storage - * cleanup keeps a deleted thread's worktree while any of its effects failed. - */ +/** Receipts deduplicate retries, and restart re-enqueues need far less than a week. Retain failed rows to keep deleted-thread worktrees. */ export const SETTLED_EFFECT_RETENTION = Duration.days(7); const PRUNE_BATCH_SIZE = 500; @@ -321,8 +316,7 @@ export const layer: Layer.Layer = La available, Array.from({ length: Math.min(64, Math.max(0, Math.floor(count))) }, () => undefined), ).pipe(Effect.asVoid); - // An earlier effect waiting out a retry backoff still blocks later ones, so - // a turn cannot start while a failed rollback is about to restore files. + // Retry backoff must block later effects so turns cannot race a pending rollback. const claimableCandidatePredicate = ( availableBefore?: string, excludeRestartContinuations = false, @@ -542,9 +536,7 @@ export const layer: Layer.Layer = La const leaseExpiresAt = DateTime.formatIso( DateTime.add(now, { milliseconds: Math.max(1, leaseDurationMs) }), ); - // Empty safety claims are expected while the daemon is idle. Tracing - // this query records the full SQL text on every poll and can dominate - // the local trace without adding actionable information. + // Idle claims return empty; tracing their full SQL on every poll overwhelms useful traces. const claimStatement = sql` UPDATE orchestration_v2_effect_outbox SET @@ -695,8 +687,7 @@ export const layer: Layer.Layer = La const cutoff = DateTime.formatIso( DateTime.subtractDuration(yield* DateTime.now, SETTLED_EFFECT_RETENTION), ); - // Each batch is its own statement, so a large backlog never holds the - // write lock for long and writers can commit between batches. + // Separate batch statements release the write lock between batches. const deleteBatch = sql<{ readonly effect_id: string }>` DELETE FROM orchestration_v2_effect_outbox WHERE rowid IN ( diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 671d7902fb45c..724e38f875581 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -9,6 +9,7 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, VcsProcessExitError, } from "@t3tools/contracts"; @@ -83,6 +84,7 @@ function layerExecutorFor(input: { readonly continueAfterRestart?: boolean; readonly interrupt?: ProviderTurnControlService.ProviderTurnControlServiceV2Shape["interrupt"]; readonly rollback?: CheckpointRollbackService.CheckpointRollbackServiceV2Shape["execute"]; + readonly respond?: RuntimeRequestService.RuntimeRequestServiceV2["Service"]["respond"]; }) { const record = (event: string) => Ref.update(input.events, (events) => [...events, event]); const layerDependencies = Layer.mergeAll( @@ -142,7 +144,9 @@ function layerExecutorFor(input: { ), Layer.succeed( RuntimeRequestService.RuntimeRequestServiceV2, - RuntimeRequestService.RuntimeRequestServiceV2.of({ respond: () => Effect.void }), + RuntimeRequestService.RuntimeRequestServiceV2.of({ + respond: input.respond ?? (() => Effect.void), + }), ), Layer.succeed( ThreadTitleRegenerationService.ThreadTitleRegenerationService, @@ -269,6 +273,42 @@ it.effect.each([ }), ); +it.effect("does not report an answer whose last delivery attempt is interrupted", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const reported = yield* Ref.make(0); + const layer = layerExecutorFor({ + events: yield* Ref.make>([]), + respond: () => Effect.interrupt, + threads: { + dispatch: (command) => + Ref.update(reported, (count) => + command.type === "runtime-request.delivery.fail" ? count + 1 : count, + ).pipe(Effect.as({ sequence: 1, storedEvents: [] })), + }, + }); + const exit = yield* Effect.gen(function* () { + const executor = yield* EffectWorker.OrchestrationEffectExecutorV2; + return yield* Effect.exit( + executor.execute( + { + ...restartEffect(now, { type: "detach" }), + request: { + type: "runtime-request.respond", + providerSessionId: oldSessionId, + requestId: RuntimeRequestId.make("runtime-request:effect-worker-interrupted"), + answers: { next: "Ship it" }, + }, + }, + { willRetry: false }, + ), + ); + }).pipe(Effect.provide(layer)); + assert.isTrue(Exit.hasInterrupts(exit)); + assert.equal(yield* Ref.get(reported), 0); + }), +); + it.effect("requeues a claim when a pre-execution worker check fails", () => Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index f59b9094de6e2..ca1f4f399cf37 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -266,8 +266,7 @@ export const layerExecutor: Layer.Layer< const message = projection.messages.find((item) => item.id === messageId); const run = projection.runs.find((item) => item.id === message?.runId); if (message === undefined || run === undefined) return yield* error; - // Reuse the message identity and a stable command receipt so an outbox - // retry cannot append a duplicate message or start a second follow-up. + // Reuse message ids and receipts so retries cannot duplicate messages or follow-ups. yield* threads .dispatch({ type: "message.dispatch", @@ -302,11 +301,7 @@ export const layerExecutor: Layer.Layer< : { senderThreadId: message.senderThreadId }), }) .pipe( - // The turn that just completed leaves no further retry - // target once the instance is off: the completed turn - // stands as the thread's result instead of going - // through the outbox's bounded retry on a refusal that - // retrying cannot change. + // A disabled instance cannot retry this completed turn; its result stands. Effect.catchTags({ ThreadManagementProviderInstanceDisabledError: (disabledError) => Effect.logInfo( @@ -378,7 +373,8 @@ export const layerExecutor: Layer.Layer< }), ), ); - case "runtime-request.respond": + case "runtime-request.respond": { + const requestId = effect.request.requestId; return runtimeRequests .respond({ threadId: effect.threadId, @@ -395,6 +391,25 @@ export const layerExecutor: Layer.Layer< : { answers: effect.request.answers }), }) .pipe( + Effect.tapCause((cause) => + willRetry || Cause.hasInterruptsOnly(cause) + ? Effect.void + : threads + .dispatch({ + type: "runtime-request.delivery.fail", + commandId: CommandId.make(`${effect.commandId}:delivery-failed`), + threadId: effect.threadId, + requestId, + }) + .pipe( + Effect.catchCause((recordCause) => + Effect.logWarning("Failed to record answer delivery failure", { + effectId: effect.id, + cause: recordCause, + }), + ), + ), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -404,6 +419,7 @@ export const layerExecutor: Layer.Layer< }), ), ); + } case "provider-thread.rollback": return checkpointRollback .execute({ @@ -706,10 +722,7 @@ export const layerWithOptions = ( return false; } const effect = claimed.value; - // Arm the process-local cancellation signal before re-reading durable - // state. A cancellation that commits after the row read has begun can - // then still win the execution race instead of falling into the gap - // between the read and signal registration. + // Arm cancellation before rereading durable state to close the read-to-registration race. const cancellation = outbox .awaitCancellation(effect.id) .pipe(Effect.as("cancelled" as const)); @@ -726,10 +739,7 @@ export const layerWithOptions = ( ), Duration.millis(Math.max(0, claimedAt - eligibleAt)), ); - // Cancellation can commit after the durable claim but before the - // process-local Deferred is registered. Re-read the authoritative row - // once before starting external work; later cancellations use the - // Deferred raced below. + // Recheck durable cancellation after registering the Deferred; cancellation may have committed after claim. if (yield* wasCancelled(effect.id)) { yield* outbox.clearCancellation(effect.id); return true; @@ -854,9 +864,7 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions = const livenessPollIntervalMs = Number.isFinite(requestedLivenessPollIntervalMs) ? Math.max(1, Math.floor(requestedLivenessPollIntervalMs)) : DEFAULT_EFFECT_WORKER_LIVENESS_POLL_INTERVAL_MS; - // Post-commit notifications are the low-latency path. `availableAt` is the - // durable retry schedule, and the long liveness poll only recovers from a - // missed in-process notification or work inserted by another process. + // Notifications wake promptly; availableAt persists retry timing, and liveness polling recovers missed wakeups. const runWorker = Effect.gen(function* () { while (true) { const outcome = yield* worker.runOnce.pipe( @@ -872,9 +880,7 @@ export const runDaemonWithOptions = (options: OrchestrationEffectDaemonOptions = continue; } if (outcome === "failed") { - // A due row can remain visible when a claim UPDATE fails. Do not - // feed that past deadline back into the scheduler and retry at the - // one-millisecond floor; let transient database failures cool off. + // Failed claims can leave overdue rows; cool off instead of rescheduling at the one-millisecond floor. yield* Effect.sleep(Duration.millis(Math.min(1_000, livenessPollIntervalMs))); continue; } diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 3d2bc8e4b45a8..af8fa5a95af7b 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -108,12 +108,7 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; - /** - * Atomically commit only when the provider thread is still owned by the - * expected run attempt and ordinal. Used for late post-terminal - * provider_thread updates so a completed or superseded attempt cannot clobber - * a newer attempt that already claimed the thread. - */ + /** Commits only if the expected attempt and ordinal still own the provider thread, protecting newer attempts. */ readonly writeIfProviderThreadOwner: (input: { readonly guardPendingUserInputCancellations?: boolean; readonly commandId?: CommandId; @@ -129,12 +124,7 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; - /** - * Atomically commit only while the stored provider thread still equals - * `expected`, the row its writer read, so a read-modify-write of the row - * cannot overwrite an update published in between. The whole row is - * compared because not every writer moves `updatedAt`. - */ + /** Commits only if the full provider row equals expected; some writers do not advance updatedAt. */ readonly writeIfProviderThreadUnchanged: (input: { readonly expected: OrchestrationV2ProviderThread; readonly events: ReadonlyArray; @@ -262,16 +252,13 @@ const layerBase: Layer.Layer< Effect.andThen(publishLiveEvents(events)), ); - // Transactions commit one at a time, but each writer publishes after its - // commit. If a writer is descheduled in between, a later commit reaches - // subscribers first, and clients drop any event at or below the newest - // sequence they have applied. + // Serialize commit and publish; reordered publication makes clients drop earlier sequences. const publishLane = yield* Semaphore.make(1); const commitThenPublish = ( transaction: Effect.Effect, publish: (committed: A) => Effect.Effect, ) => - Effect.suspend(() => { + Effect.uninterruptibleMask((restore) => { let holdsLane = false; const takeLane = publishLane.take(1).pipe( Effect.andThen( @@ -281,8 +268,11 @@ const layerBase: Layer.Layer< ), Effect.uninterruptible, ); + // Cancellation may roll back the transaction's work. Once it commits, + // every subscriber must hear about it before this writer can exit. + // Otherwise a failed child run can leave its parent's task running. return sql - .withTransaction(Effect.tap(transaction, () => takeLane)) + .withTransaction(Effect.tap(restore(transaction), () => takeLane)) .pipe( Effect.tap(publish), Effect.ensuring( @@ -1000,10 +990,7 @@ const layerBase: Layer.Layer< }), ); -/** - * Keeping the outbox instance shared with the worker is important because enqueue notifications - * are in-memory wakeups backed by the durable SQL queue. - */ +/** Share the worker's outbox instance: enqueue wakeups are in-memory. */ export const layerFromStores = layerBase; export const layer: Layer.Layer< diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index a37bcf8f2cd86..93af17983c782 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -804,9 +804,6 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { `; const summary = yield* maintenance.compactEventStore; - // Superseded state spans several discovery pages. Both turn-item updates - // stay, and so do the first message and node events (replay order). The - // rename stays too: visits are read state and only supersede each other. assert.isAtLeast(summary.deletedEventCount, 504); assert.isAtLeast(summary.deletedReceiptCount, 1); @@ -3298,3 +3295,107 @@ it.effect("loads a shell snapshot while the writer connection is occupied", () = }).pipe(Effect.provide(projectionLayer)); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect.each(["cancel transaction", "fail transaction", "cancel publication"] as const)( + "preserves event consistency and allows later writes after %s", + (scenario) => + Effect.gen(function* () { + const paused = yield* Deferred.make(); + const release = yield* Deferred.make(); + const committed = scenario === "cancel publication"; + const pause = Deferred.succeed(paused, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const layerPausingOutbox = Layer.effect( + EffectOutbox.EffectOutboxV2, + Effect.gen(function* () { + const delegate = yield* EffectOutbox.EffectOutboxV2; + return EffectOutbox.EffectOutboxV2.of({ + ...delegate, + enqueue: (effects) => + delegate + .enqueue(effects) + .pipe( + Effect.andThen(!committed && effects.length > 0 ? pause : Effect.void), + Effect.andThen( + scenario === "fail transaction" && effects.length > 0 + ? Effect.die("Injected transaction failure") + : Effect.void, + ), + ), + notifyAvailable: (count) => + (committed ? pause : Effect.void).pipe( + Effect.andThen(delegate.notifyAvailable(count)), + ), + }); + }), + ).pipe(Layer.provide(layerEffectOutboxProvided)); + const layerEventSink = EventSink.layerFromStores.pipe( + Layer.provide( + Layer.mergeAll( + layerStoresProvided, + layerPausingOutbox, + layerCommandReceiptStoreProvided, + ProjectStore.layer.pipe(Layer.provide(layerDatabase)), + TurnItemPositionStore.layer.pipe(Layer.provide(layerDatabase)), + ), + ), + ); + + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const now = yield* DateTime.now; + const first = makeThread(ThreadId.make("thread:interrupted-publish:first"), now); + const marker = makeThread(ThreadId.make("thread:interrupted-publish:marker"), now); + const published = yield* eventSink + .stream({ afterSequence: yield* eventSink.latestSequence() }) + .pipe( + Stream.takeUntil((stored) => stored.event.threadId === marker.id), + Stream.runCollect, + Effect.forkScoped({ startImmediately: true }), + ); + const writer = yield* eventSink + .writeWithEffects({ + events: [ + threadCreatedEvent({ id: "event:interrupted-publish:first", thread: first, now }), + ], + effects: [ + { + id: "effect:interrupted-publish:first", + commandId: CommandId.make("command:interrupted-publish:first"), + threadId: first.id, + request: { type: "terminal.cleanup" }, + }, + ], + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(paused); + if (scenario !== "fail transaction") writer.interruptUnsafe(); + if (scenario !== "cancel transaction") yield* Deferred.succeed(release, undefined); + assert.equal((yield* Fiber.await(writer))._tag, "Failure"); + assert.equal( + (yield* projections.getThreadShell(first.id))?.id ?? null, + committed ? first.id : null, + ); + assert.equal( + Option.isSome(yield* outbox.get("effect:interrupted-publish:first")), + committed, + ); + yield* eventSink.write({ + events: [ + threadCreatedEvent({ id: "event:interrupted-publish:marker", thread: marker, now }), + ], + }); + assert.deepEqual( + Array.from(yield* Fiber.join(published), (stored) => stored.event.threadId), + committed ? [first.id, marker.id] : [marker.id], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(layerEventSink, layerStoresProvided, layerEffectOutboxProvided), + ), + ); + }).pipe(Effect.provide(layerDatabase)), +); diff --git a/apps/server/src/orchestration-v2/GuestShare.test.ts b/apps/server/src/orchestration-v2/GuestShare.test.ts new file mode 100644 index 0000000000000..d789b206016b6 --- /dev/null +++ b/apps/server/src/orchestration-v2/GuestShare.test.ts @@ -0,0 +1,343 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + GuestMessageId, + GuestShareId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for guest shares"), +} as ProviderAdapter.ProviderAdapterV2["Service"]; +const layerDatabase = SqlitePersistence.layerMemory; +const layerTest = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "guest-share" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), +); + +const threadId = ThreadId.make("thread:guest-share"); +const shareId = GuestShareId.make("share:one"); +const guestMessageId = GuestMessageId.make("guest-message:one"); + +const setup = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-guest-share"), + threadId, + projectId: ProjectId.make("project:guest-share"), + title: "Review me", + modelSelection: { instanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.open", + commandId: CommandId.make("open-guest-share"), + threadId, + shareId, + host: "env.t3.test", + expiresAt: DateTime.formatIso(DateTime.add(yield* DateTime.now, { hours: 24 })), + }); + return orchestrator; +}); + +const sendGuestMessage = (orchestrator: Orchestrator.OrchestratorV2["Service"], id: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + guestMessageId, + text: "ignored: the server sends the guest's own text", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + +it.effect("redeems a guest link once and sends each guest message once, with its turn", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const redeem = (id: string) => + orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make(id), + threadId, + shareId, + guestName: "Sam", + }); + yield* redeem("redeem-1"); + assert.isTrue(Exit.isFailure(yield* Effect.exit(redeem("redeem-2")))); + + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit-1"), + threadId, + shareId, + guestMessageId, + text: "Why not keep the old cache?", + }); + assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 1); + + yield* sendGuestMessage(orchestrator, "send-1"); + const projection = yield* projections.getThreadProjection(threadId); + assert.deepEqual( + projection.thread.guestShare?.messages.map((message) => message.status), + ["sent"], + ); + const sent = projection.messages.find((message) => message.id === MessageId.make("send-1")); + assert.equal( + sent?.text, + "Message from guest Sam, sent by the owner:\n\nWhy not keep the old cache?", + ); + assert.deepEqual(sent?.context?.guest, { + guestMessageId, + name: "Sam", + text: "Why not keep the old cache?", + shareId, + }); + assert.lengthOf(projection.runs, 1); + assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 0); + + // A second owner device clicking Send must not start another turn. + assert.isTrue(Exit.isFailure(yield* Effect.exit(sendGuestMessage(orchestrator, "send-2")))); + assert.lengthOf((yield* projections.getThreadProjection(threadId)).runs, 1); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("rejects guest messages until the link is redeemed and after archive", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const submit = orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit-early"), + threadId, + shareId, + guestMessageId, + text: "Hello", + }); + assert.isTrue(Exit.isFailure(yield* Effect.exit(submit))); + + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-guest-share"), + threadId, + }); + assert.isNull((yield* projections.getThread(threadId)).guestShare ?? null); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("drops guest attribution that a client sends on its own message", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-message"), + threadId, + messageId: MessageId.make("owner-message"), + text: "Mine", + context: { version: 1, records: [], guest: { guestMessageId, name: "Sam", text: "Mine" } }, + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const projection = yield* projections.getThreadProjection(threadId); + const sent = projection.messages.find( + (message) => message.id === MessageId.make("owner-message"), + ); + assert.isUndefined(sent?.context?.guest); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("keeps a queued guest message unchanged when the owner tries to edit it", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-first"), + threadId, + messageId: MessageId.make("owner-first"), + text: "Start", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("send-queued"), + threadId, + messageId: MessageId.make("send-queued"), + guestMessageId, + text: "", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* projections.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + ); + assert.isDefined(queued); + + const edit = orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit"), + threadId, + runId: queued.id, + text: "My own words", + }); + assert.isTrue(Exit.isFailure(yield* Effect.exit(edit))); + const message = (yield* projections.getThreadProjection(threadId)).messages.find( + (candidate) => candidate.id === MessageId.make("send-queued"), + ); + assert.equal(message?.context?.guest?.text, "Why?"); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("shows a guest message as dismissed when the owner cancels its queued send", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("owner-first"), + threadId, + messageId: MessageId.make("owner-first"), + text: "Start", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("send-queued"), + threadId, + messageId: MessageId.make("send-queued"), + guestMessageId, + text: "", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + const queued = (yield* projections.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + ); + assert.isDefined(queued); + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel"), + threadId, + runId: queued.id, + }); + const { thread } = yield* projections.getThreadProjection(threadId); + assert.equal( + thread.guestShare?.messages.find((message) => message.id === guestMessageId)?.status, + "dismissed", + ); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("brings a settled thread back when its guest sends a request", () => + Effect.gen(function* () { + const orchestrator = yield* setup; + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* orchestrator.dispatch({ + type: "thread.guest-share.redeem", + commandId: CommandId.make("redeem"), + threadId, + shareId, + guestName: "Sam", + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle"), + threadId, + }); + assert.equal( + (yield* projections.getThreadProjection(threadId)).thread.settledOverride, + "settled", + ); + yield* orchestrator.dispatch({ + type: "thread.guest-message.submit", + commandId: CommandId.make("submit"), + threadId, + shareId, + guestMessageId, + text: "Why?", + }); + const { thread } = yield* projections.getThreadProjection(threadId); + assert.isNull(thread.settledOverride); + assert.isNull(thread.settledAt); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/GuestShareDecider.ts b/apps/server/src/orchestration-v2/GuestShareDecider.ts new file mode 100644 index 0000000000000..00f9f8ae3b9d2 --- /dev/null +++ b/apps/server/src/orchestration-v2/GuestShareDecider.ts @@ -0,0 +1,255 @@ +import { + GUEST_PENDING_ATTACHMENT_BYTES_LIMIT, + GUEST_PENDING_MESSAGE_LIMIT, + GUEST_RESOLVED_MESSAGE_LIMIT, + type GuestMessageAttribution, + type GuestMessageId, + type OrchestrationMessageContext, + type OrchestrationV2AppThread, + type OrchestrationV2ServerCommand, + type ThreadGuestMessage, + type ThreadGuestShare, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Result from "effect/Result"; + +export type GuestShareCommand = Extract< + OrchestrationV2ServerCommand, + { + readonly type: + | "thread.guest-share.open" + | "thread.guest-share.redeem" + | "thread.guest-share.close" + | "thread.guest-message.submit" + | "thread.guest-message.dismiss"; + } +>; + +/** Failures a guest may be told about. Any other dispatch failure reaches them as "rejected". */ +export const GUEST_FAILURE = { + inactive: "This guest link is no longer active.", + used: "This guest link was already used.", + busy: "Wait for the owner to answer your earlier messages.", + filesBusy: "Wait for the owner to answer your earlier messages before you attach more files.", +} as const; + +const isExpired = (share: ThreadGuestShare, now: DateTime.Utc) => + DateTime.toEpochMillis(now) >= Date.parse(share.expiresAt); + +const pendingAttachments = (share: ThreadGuestShare | null) => + share?.messages.flatMap((message) => + message.status === "pending" ? (message.attachments ?? []) : [], + ) ?? []; + +/** + * Stored files a share change leaves behind: those of waiting messages that were dismissed or + * dropped with the share. A sent message keeps its files, which its thread message now holds. + */ +export function releasedGuestAttachmentIds( + before: ThreadGuestShare | null, + after: ThreadGuestShare | null, +): ReadonlyArray { + const kept = new Set( + after !== null && after.id === before?.id + ? after.messages.flatMap((message) => + message.status === "dismissed" + ? [] + : (message.attachments ?? []).map((attachment) => attachment.id), + ) + : [], + ); + return pendingAttachments(before) + .map((attachment) => attachment.id) + .filter((id) => !kept.has(id)); +} + +/** Keeps every pending message and only the newest resolved ones. */ +function trimResolved(messages: ReadonlyArray): Array { + let resolvedBudget = GUEST_RESOLVED_MESSAGE_LIMIT; + const kept: Array = []; + for (const message of messages.toReversed()) { + if (message.status !== "pending") { + if (resolvedBudget === 0) continue; + resolvedBudget -= 1; + } + kept.push(message); + } + return kept.toReversed(); +} + +const resolveMessage = ( + share: ThreadGuestShare, + guestMessageId: GuestMessageId, + status: "sent" | "dismissed", +): Result.Result => { + const message = share.messages.find((candidate) => candidate.id === guestMessageId); + if (message === undefined || message.status !== "pending") { + return Result.fail(`Guest message ${guestMessageId} is not pending.`); + } + return Result.succeed({ + ...share, + messages: trimResolved( + share.messages.map((candidate) => + candidate.id === guestMessageId ? { ...candidate, status } : candidate, + ), + ), + }); +}; + +/** + * Applies a guest share command to the thread. The orchestrator runs commands one at a time, so + * checking state here is what makes a link redeem once and a message send once. + */ +export function decideGuestShareCommand(input: { + readonly thread: OrchestrationV2AppThread; + readonly command: GuestShareCommand; + readonly now: DateTime.Utc; +}): Result.Result { + const { thread, command, now } = input; + const share = thread.guestShare ?? null; + const withShare = (guestShare: ThreadGuestShare | null) => + Result.succeed({ ...thread, guestShare, updatedAt: now }); + + switch (command.type) { + case "thread.guest-share.open": { + if (thread.archivedAt !== null) return Result.fail("Archived threads cannot be shared."); + return withShare({ + id: command.shareId, + host: command.host, + createdAt: DateTime.formatIso(now), + expiresAt: command.expiresAt, + guestName: null, + messages: [], + }); + } + case "thread.guest-share.close": + return share === null ? Result.succeed(thread) : withShare(null); + case "thread.guest-share.redeem": { + if (share === null || share.id !== command.shareId || isExpired(share, now)) { + return Result.fail(GUEST_FAILURE.inactive); + } + if (share.guestName !== null) return Result.fail(GUEST_FAILURE.used); + return withShare({ ...share, guestName: command.guestName }); + } + case "thread.guest-message.submit": { + if ( + share === null || + share.id !== command.shareId || + share.guestName === null || + isExpired(share, now) + ) { + return Result.fail(GUEST_FAILURE.inactive); + } + if ( + share.messages.filter((message) => message.status === "pending").length >= + GUEST_PENDING_MESSAGE_LIMIT + ) { + return Result.fail(GUEST_FAILURE.busy); + } + const attachments = command.attachments ?? []; + const pendingBytes = [...pendingAttachments(share), ...attachments].reduce( + (total, attachment) => total + attachment.sizeBytes, + 0, + ); + if (attachments.length > 0 && pendingBytes > GUEST_PENDING_ATTACHMENT_BYTES_LIMIT) { + return Result.fail(GUEST_FAILURE.filesBusy); + } + const guestShare: ThreadGuestShare = { + ...share, + messages: trimResolved([ + ...share.messages, + { + id: command.guestMessageId, + text: command.text, + status: "pending", + submittedAt: DateTime.formatIso(now), + ...(attachments.length === 0 ? {} : { attachments }), + }, + ]), + }; + // A request needs the owner, so a settled thread comes back as a new message brings it. + return thread.settledOverride === "settled" + ? Result.succeed({ + ...thread, + guestShare, + settledOverride: null, + settledAt: null, + unsettledAt: now, + updatedAt: now, + }) + : withShare(guestShare); + } + case "thread.guest-message.dismiss": { + if (share === null) return Result.fail("This thread has no guest link."); + return Result.map(resolveMessage(share, command.guestMessageId, "dismissed"), (next) => ({ + ...thread, + guestShare: next, + updatedAt: now, + })); + } + } +} + +/** + * Marks a guest message sent. `text` is what the agent receives: it names the guest so the agent + * does not mistake it for the owner's words, and so a guest cannot start a slash command. + * `guest` lets clients show the guest's own text with their name beside it. + */ +export function decideGuestMessageSend(input: { + readonly thread: OrchestrationV2AppThread; + readonly guestMessageId: GuestMessageId; + readonly now: DateTime.Utc; +}): Result.Result< + { + readonly thread: OrchestrationV2AppThread; + readonly text: string; + readonly guest: GuestMessageAttribution; + }, + string +> { + const share = input.thread.guestShare ?? null; + if (share === null) return Result.fail("This thread has no guest link."); + const message = share.messages.find((candidate) => candidate.id === input.guestMessageId); + const name = share.guestName ?? "Guest"; + const guestText = message?.text ?? ""; + return Result.map(resolveMessage(share, input.guestMessageId, "sent"), (next) => ({ + thread: { ...input.thread, guestShare: next, updatedAt: input.now }, + text: `Message from guest ${name}, sent by the owner:\n\n${guestText}`, + guest: { guestMessageId: input.guestMessageId, name, text: guestText, shareId: share.id }, + })); +} + +/** + * The owner cancelled a queued send, so the agent never got the message. It shows as dismissed, + * which the guest can see, instead of sent with no reply ever coming. A share that changed since + * the send is left alone. + */ +export function guestShareAfterCancelledSend( + thread: OrchestrationV2AppThread, + guest: Pick, + now: DateTime.Utc, +): OrchestrationV2AppThread { + const share = thread.guestShare ?? null; + if (share === null || share.id !== guest.shareId) return thread; + const message = share.messages.find((candidate) => candidate.id === guest.guestMessageId); + if (message?.status !== "sent") return thread; + return { + ...thread, + guestShare: { + ...share, + messages: share.messages.map((candidate) => + candidate.id === guest.guestMessageId ? { ...candidate, status: "dismissed" } : candidate, + ), + }, + updatedAt: now, + }; +} + +/** Guest attribution comes only from `decideGuestMessageSend`; a client cannot claim it. */ +export function withoutGuestAttribution< + C extends { readonly context?: OrchestrationMessageContext | undefined }, +>(command: C): C { + if (command.context?.guest === undefined) return command; + const { guest: _guest, ...context } = command.context; + return { ...command, context }; +} diff --git a/apps/server/src/orchestration-v2/IdleCompactionService.ts b/apps/server/src/orchestration-v2/IdleCompactionService.ts index 30c81ba703323..2b16cb8c1d73d 100644 --- a/apps/server/src/orchestration-v2/IdleCompactionService.ts +++ b/apps/server/src/orchestration-v2/IdleCompactionService.ts @@ -1,5 +1,6 @@ import { CommandId, + IDLE_COMPACTION_TEXT, MessageId, resolveIdleCompaction, resolveProjectProviderInstanceEnabled, @@ -70,10 +71,11 @@ export const make = Effect.gen(function* () { commandId: CommandId.make(`server:idle-compact:${threadId}:${runId}`), messageId: MessageId.make(`idle-compact:${threadId}:${runId}`), threadId, - text: "/compact", + text: IDLE_COMPACTION_TEXT, attachments: [], createdBy: "agent", creationSource: "server", + idleCompaction: "beforeCacheExpires", dispatchMode: { type: "start_immediately" }, }); return; diff --git a/apps/server/src/orchestration-v2/LiveStreamBudget.ts b/apps/server/src/orchestration-v2/LiveStreamBudget.ts index c4ace088c574a..39ddf5509a898 100644 --- a/apps/server/src/orchestration-v2/LiveStreamBudget.ts +++ b/apps/server/src/orchestration-v2/LiveStreamBudget.ts @@ -23,8 +23,7 @@ export interface RetainedLiveItem { readonly serializedBytes: number; } -// Published events are immutable and shared across subscriptions. Measure each -// object once without keeping the event or its serialized copy alive. +// Immutable events are shared across subscribers; cache sizes without retaining events or serialized copies. const serializedSizes = new WeakMap(); function serializedSize(value: object): number { @@ -154,8 +153,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* Effect.catchTags({ LiveStreamBufferError: (error) => Effect.sync(() => { - // A grouped source can retain its own pending chunk. Close it - // and release the pull closure without waiting for an RPC ACK. + // Release retained source chunks without waiting for an RPC ACK. source.pull = Effect.interrupt; }).pipe( Effect.andThen(Scope.close(sourceScope, Exit.fail(error))), @@ -176,8 +174,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* ); // @effect-diagnostics-next-line returnEffectInGen:off - Stream.fromPull needs the pull effect as its result. return Effect.gen(function* () { - // RpcServer requests the next batch only after the client ACKs this - // one. Removing items from a queue alone does not mean delivery ended. + // RpcServer requests the next batch after ACK; dequeueing does not end delivery. release(inFlight); inFlight = []; yield* check; @@ -366,8 +363,7 @@ export const replayAndBufferProjectedLiveEvents = < const subscription = yield* input.subscribe.pipe(Scope.provide(subscriptionScope)); let throughSequence = input.afterSequence ?? 0; const live = yield* retainLiveStream( - // Taking one event at a time avoids retaining an uncharged PubSub batch - // while the drain charges events to the budget. + // Take one event at a time so no uncharged PubSub batch is retained. Stream.fromEffectRepeat(PubSub.take(subscription)).pipe( Stream.filter( (event) => event.sequence > throughSequence && (input.filter?.(event) ?? true), diff --git a/apps/server/src/orchestration-v2/NotificationMailbox.ts b/apps/server/src/orchestration-v2/NotificationMailbox.ts index 2afee9749e22e..29ba40c5777fe 100644 --- a/apps/server/src/orchestration-v2/NotificationMailbox.ts +++ b/apps/server/src/orchestration-v2/NotificationMailbox.ts @@ -16,10 +16,7 @@ export function isBufferedProviderNotification( ); } -/** - * Delivery is at least once: provider acceptance and our receipt cannot commit - * atomically. Reusing the message ID keeps recovery from duplicating timeline items. - */ +/** Acceptance and receipts cannot commit atomically; reuse message ids for at-least-once delivery. */ export function isUndeliveredMailboxSteer( projection: Pick, messageId: MessageId, diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 187c25935a07b..eea36ac3b769d 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -60,10 +60,7 @@ const event = (type: string, data: Record): ProviderReplayEntry }); const labelled = (entry: ProviderReplayEntry, label: string): ProviderReplayEntry => entry.type === "runtime_exit" ? entry : { ...entry, label }; -/** - * T3's own rules after a mode's: every thread's T3 MCP server is denied, and - * then this thread's own is allowed again (last match wins). - */ +/** MCP rules are last-match-wins; allow this thread after denying all T3 servers. */ const mcpRules = (name: string) => [ { action: "t3-code-*", resource: "*", effect: "deny" }, { action: `t3-code-thread_${name}_*`, resource: "*", effect: "allow" }, @@ -856,10 +853,7 @@ describe("OpenCode 2 through the orchestrator", () => { "settles a background subagent whose report OpenCode delivers into the launching turn", () => Effect.gen(function* () { - // The recorded background run, reordered as 2.0.18 ran it live when the - // child ended first: the parent's execution is still answering when the - // child's report is queued and delivered into it, and that one - // execution then answers the report and ends. + // OpenCode 2.0.18 can deliver a child report into the parent's still-running execution, which then answers it. const cwd = yield* checkpointWorkspace("opencode2-background-in-turn"); const recorded = yield* readProviderReplayTranscript( diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 2a89fc7ccbe41..66a339ee14476 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1287,7 +1287,8 @@ it.effect("replays a thread.create receipt without changing the thread", () => const before = yield* projections.getThread(threadId); const replayed = yield* orchestrator.dispatch(create); assert.equal(replayed.sequence, original.sequence); - assert.deepEqual(replayed, original); + // Marked so client analytics do not count the retry again. + assert.deepEqual(replayed, { ...original, replayed: true }); assert.deepEqual(yield* projections.getThread(threadId), before); assert.equal(yield* orchestrator.getThreadEventSequence(threadId), original.sequence); }).pipe(Effect.provide(layerTest)), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 942361c7eb789..599fcca0e5804 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,4 +1,5 @@ import type { + GuestShareId, OrchestrationV2SearchThreadInput, OrchestrationV2SearchThreadResult, OrchestrationV2ThreadHistoryPage, @@ -11,6 +12,7 @@ import { usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; import { linkedCheckoutsAfter } from "@t3tools/shared/checkouts"; +import { canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-definitions/registry"; import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import { normalizeThreadPullRequestKey, @@ -22,6 +24,7 @@ import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type ChatAttachment, CommandId, + type GuestMessageId, isOrchestrationV2WorkActive, isProviderNativeSubagentThread, MessageId, @@ -65,7 +68,7 @@ import { RuntimeMode, ThreadLinkedPullRequest, ThreadId, - type TurnItemId, + TurnItemId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { modelChangeSourceSelection, modelChangeTurnItem } from "@t3tools/shared/modelChangeMarker"; @@ -84,6 +87,7 @@ import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -111,6 +115,14 @@ import { isUndeliveredMailboxSteer, } from "./NotificationMailbox.ts"; import { EventSinkV2 } from "./EventSink.ts"; +import { + decideGuestMessageSend, + guestShareAfterCancelledSend, + releasedGuestAttachmentIds, + withoutGuestAttribution, + decideGuestShareCommand, + type GuestShareCommand, +} from "./GuestShareDecider.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -127,6 +139,8 @@ import { type ProjectionRecordFilter, type ProjectionRecords, type ProjectionCheckpointContext, + type OlderSettledThreadsListing, + type OlderSettledThreadsQuery, type ShellSnapshotOptions, } from "./ProjectionStore.ts"; import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; @@ -302,6 +316,8 @@ export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; export interface OrchestratorV2DispatchResult { readonly sequence: number; readonly storedEvents: ReadonlyArray; + /** The command id was already accepted; analytics skip it. */ + readonly replayed?: true; } export interface OrchestratorV2Shape { @@ -313,9 +329,7 @@ export interface OrchestratorV2Shape { taskId?: NodeId, ) => Effect.Effect; readonly restartContinuationPending: (run: OrchestrationV2Run) => Effect.Effect; - /** - * A declined one records no thread event, so a waiter needs this to wake. - */ + /** Declined continuations emit no thread event; this still wakes waiters. */ readonly awaitRestartContinuation: (run: OrchestrationV2Run) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2ServerCommand, @@ -383,6 +397,9 @@ export interface OrchestratorV2Shape { ReadonlyArray, OrchestratorV2Error >; + readonly listOlderSettledThreads: ( + query: OlderSettledThreadsQuery, + ) => Effect.Effect; readonly readShellSnapshot: ( options?: ShellSnapshotOptions, ) => Effect.Effect< @@ -392,6 +409,9 @@ export interface OrchestratorV2Shape { readonly getThreadShell: ( threadId: ThreadId, ) => Effect.Effect; + readonly findGuestShareThreadId: ( + shareId: GuestShareId, + ) => Effect.Effect; readonly getThreadEventSequence: ( threadId: ThreadId, ) => Effect.Effect; @@ -413,10 +433,7 @@ function nextRunOrdinal(projection: Pick Math.max(max, run.ordinal), 0) + 1; } -/** - * Stamp it when the wake run starts, not when it queues: a queued prompt ahead - * of it has no start yet, and delegated results jump the queue. - */ +/** Stamp at wake start; queued prompts lack start times and delegated results jump the queue. */ function wakeWorkStartedAt( runs: ReadonlyArray, trigger: { @@ -456,6 +473,11 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.archive": case "thread.unarchive": case "thread.delete": + case "thread.guest-share.open": + case "thread.guest-share.redeem": + case "thread.guest-share.close": + case "thread.guest-message.submit": + case "thread.guest-message.dismiss": case "thread.settle": case "thread.auto-settle": case "thread.unsettle": @@ -504,6 +526,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "queued-run.cancel": case "queued-run.edit": case "runtime-request.respond": + case "runtime-request.delivery.fail": case "thread.user-input.dismiss": case "checkpoint.rollback": case "checkpoint.rollback.fail": @@ -569,6 +592,8 @@ function delegatedTasksStopEffect(command: { } const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; +const RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE = + "T3 Code could not confirm that your answer reached the agent. If the run stays stuck, stop it and try again."; function withoutPreparationResult( item: Extract, @@ -600,11 +625,7 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { ); } -/** - * A parent thread is "live" for wake purposes while a run is still producing - * agent output. A run parked at "waiting" is post-terminal drain, so its agent - * turn is over and a wake is still needed. - */ +/** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ function hasLiveRun(projection: Pick): boolean { return projection.runs.some( (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", @@ -633,7 +654,7 @@ function legacyPullRequestLink( return withPullRequestWatch( { ...key, - url: linked.url, + url: canonicalizeGitCafePullRequestUrl(linked.url), source: "manual", linkedAt: DateTime.formatIso(now), snapshot: null, @@ -1074,8 +1095,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return withId; }); - // This runs inside the write transaction for every command, so finished leaf - // nodes of finished runs stay unread: busy threads hold thousands of them. + // Runs in every command transaction; avoid reading thousands of finished leaf nodes. const readCommandProjection = (threadId: ThreadId) => projectionStore .getThreadRecords( @@ -1482,8 +1502,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (usageLimitHoldsQueue(projection, queuedRun.providerInstanceId)) { return; } - // A message queued behind a worktree that was cancelled or never created - // would run in the project checkout instead, so it waits for the user. + // Failed worktree creation would run queued work in the project checkout; wait for the user. const failedRun = latestExecutedRun(projection.runs); const failureClass = failedRun?.id === options?.failedRunId @@ -1530,11 +1549,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); return; } - // Run ordinals order the transcript, checkpoints, and the latest run. A - // run that started while this one waited (a resume of a held queue, a - // reorder, a delivery that jumps the queue) has a newer ordinal, so this - // run moves after it. A started run has its prompt in the transcript (a - // wake's prompt is a notification); a message cancelled from the queue has none. + // Move queued ordinals after runs that started while they waited to preserve transcript and checkpoint order. + // Started runs have prompts; cancelled queued messages do not. const newerRunIds = projection.runs .filter((run) => run.status !== "queued" && run.ordinal > queuedRun.ordinal) .map((run) => run.id); @@ -1909,6 +1925,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), }), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", @@ -2662,8 +2681,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // Checked under the thread lock: the watch or the thread can change while the host is read. - // The watch is recorded first so the wake's own thread events carry it. + // Recheck under the lock after reading the host. Record the watch first so wake events carry it. const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( function* ( command: Extract< @@ -2758,6 +2776,100 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }); + // Deleted after the share change commits: the files of waiting messages it dismissed or dropped. + const releaseGuestAttachments = ( + command: { readonly commandId: CommandId; readonly threadId: ThreadId }, + before: OrchestrationV2AppThread["guestShare"], + after: OrchestrationV2AppThread["guestShare"], + effects: Ref.Ref>, + ) => { + const attachmentIds = releasedGuestAttachmentIds(before ?? null, after ?? null); + return attachmentIds.length === 0 + ? Effect.void + : Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:guest-attachment.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { type: "attachment.cleanup", attachmentIds: [...attachmentIds] }, + } satisfies PendingOrchestrationEffectV2, + ]); + }; + + const dispatchGuestShare = Effect.fn("orchestrationV2.dispatch.guestShare")(function* ( + command: GuestShareCommand, + events: Ref.Ref>, + effects: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, []) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const thread = projection.thread; + const now = yield* DateTime.now; + const decided = + thread.deletedAt !== null + ? Result.fail(`Thread ${command.threadId} is deleted.`) + : decideGuestShareCommand({ + thread, + command, + now, + }); + if (Result.isFailure(decided)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: decided.failure, + }); + } + if (decided.success === thread) return; + yield* emit( + events, + command, + )({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: decided.success, + }); + yield* releaseGuestAttachments(command, thread.guestShare, decided.success.guestShare, effects); + }); + + // Emitted before the message so the send and the status change commit together, and so + // `dispatchMessage`, which reads pending events, keeps the updated share. + const emitGuestMessageSent = Effect.fn("orchestrationV2.dispatch.guestMessageSent")(function* ( + command: Extract, + guestMessageId: GuestMessageId, + thread: OrchestrationV2AppThread, + events: Ref.Ref>, + ) { + const now = yield* DateTime.now; + const decided = decideGuestMessageSend({ thread, guestMessageId, now }); + if (Result.isFailure(decided)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: decided.failure, + }); + } + yield* emit( + events, + command, + )({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: decided.success.thread, + }); + return decided.success; + }); + const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( command: Extract< OrchestrationV2ServerCommand, @@ -2838,8 +2950,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived and cannot watch pull requests.`, }); } - // A subagent or delegated task reports to its parent thread, which owns the pull request. - // Its own watch would wake it on every change of a PR it was not asked to babysit. + // Child tasks report to their parent, which owns PR monitoring; child watches would cause unrelated wakes. if ( command.type === "thread.pull-request.watch" && command.watching && @@ -2851,8 +2962,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is a subagent; its parent thread watches pull requests.`, }); } - // Only the agent's watch_pull_request links as "agent". A call that raced a Stop may land - // after its run ended, so the latest run that executed decides. A user can still watch. + // Agent watches use the latest executed run to catch calls racing Stop; users can still watch. if ( command.type === "thread.pull-request.watch" && command.watching && @@ -2941,9 +3051,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } - // Only pinned threads have a slot in the arranged order. Rejecting - // (rather than silently pinning) keeps a raced reorder-after-unpin from - // resurrecting a pin the user just cleared. + // Reject reorder after unpin so a raced request cannot restore a cleared pin. if (command.type === "thread.pin.reorder" && thread.pinnedAt == null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -3297,6 +3405,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, archivedAt: now, titleRegeneration: null, + guestShare: null, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), updatedAt: now, }; @@ -3333,9 +3442,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.snooze": { - // Always restamp, even for the same wake time: a re-snooze after a - // failure or completion woke the thread means "I saw it, not now". - // Retries are deduplicated by commandId before reaching here. + // Re-snoozing acknowledges the latest wake even at the same time; commandId deduplicates retries. return { ...thread, snoozedUntil, @@ -3469,8 +3576,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) ? { snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt), - // Recovery changes acknowledge the same stopped run; keep its - // metadata timestamp from appearing as a fresh failure wake. + // Recovery updates acknowledge the same stopped run; their timestamps must not trigger fresh failure wakes. snoozedAt: now, } : command.limitRecovery !== undefined && @@ -3554,14 +3660,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? { ...existing, source: command.source, - url: command.url, + url: canonicalizeGitCafePullRequestUrl(command.url), ...(command.repositoryWebUrl !== undefined && { repositoryWebUrl: command.repositoryWebUrl, }), } : { ...key, - url: command.url, + url: canonicalizeGitCafePullRequestUrl(command.url), ...(command.repositoryWebUrl !== undefined && { repositoryWebUrl: command.repositoryWebUrl, }), @@ -3582,7 +3688,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? [ { ...branchKey, - url: branch.url, + url: canonicalizeGitCafePullRequestUrl(branch.url), source: "manual" as const, linkedAt: DateTime.formatIso(now), snapshot: null, @@ -3657,7 +3763,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)), { ...key, - url: command.link.url, + url: canonicalizeGitCafePullRequestUrl(command.link.url), ...((command.link.repositoryWebUrl ?? dismissed?.repositoryWebUrl) !== undefined && { repositoryWebUrl: @@ -3873,6 +3979,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: updatedThread, }); + // Archiving ends the guest share, so its waiting files go too. + yield* releaseGuestAttachments(command, thread.guestShare, updatedThread.guestShare, effects); // Arming or cancelling auto-resume is shown in the stopped turn, as the // composer banner goes away once the thread moves on. @@ -4025,13 +4133,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - // Settle joins archive here: both mean "done with this - // thread", so a live provider session must not keep running background - // work (PR monitors, dev servers, subagent fleets) after any of them - // lands. The settle guard above already rejects active or blocked runs, - // so for settle this only ever stops an idle session; commands are - // decided serially against the projection, so a turn start that - // re-engages the thread cannot race this detach. + // Archive and settle stop idle background work; serialized commands prevent starts racing detach. + // Settlement rejects active or blocked runs. const detachSessionIds = new Set( command.type === "thread.archive" || command.type === "thread.settle" ? (providerContext?.providerSessions ?? []).map((session) => session.id) @@ -4048,13 +4151,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .map((session) => session.id) : (providerSwitchPlan?.releaseProviderSessionIds ?? []), ); - // A worktree handoff changes the workspace from inside the running turn, - // and the detach below closes that turn's session without a terminal - // event. Settle the run as interrupted in this commit, as Stop does for a - // dead session: the closed stream then finds the run settled instead of - // recording a provider failure (which would also hold the queued - // continuation), and the in-flight handoff tool call stops showing as - // background work. + // Handoff detaches the provider without a terminal event. Settle this run as + // interrupted first so stream closure cannot fail it or block the continuation, + // and its in-flight handoff tool call no longer appears as background work. if ( (command.type === "thread.metadata.update" || command.type === "thread.project.move") && detachSessionIds.size > 0 @@ -4175,9 +4274,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : command.type === "thread.runtime-mode.set" ? "Runtime mode changed." : "Provider or model selection changed.", - // Terminal detaches revoke the thread's MCP credentials; other - // detach reasons keep them so a re-attaching provider process - // stays authorized. + // Terminal detaches revoke MCP credentials; reattachments need other detach reasons to preserve them. ...(command.type === "thread.archive" ? { revokeMcpCredential: true } : {}), }, } satisfies PendingOrchestrationEffectV2; @@ -4328,8 +4425,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is not under this command's lock, so check the modes this - // command copies, not an earlier read the source's user could outrun. + // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); @@ -4423,8 +4519,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is not under this command's lock, so check the modes this - // command copies, not an earlier read the source's user could outrun. + // The source is unlocked; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); const targetProjection = yield* projectionStore .getThreadRecords(command.targetThreadId, ["contextTransfers"]) @@ -4569,6 +4664,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly creationSource: OrchestrationV2ConversationMessage["creationSource"]; readonly scheduledTaskId?: OrchestrationV2ConversationMessage["scheduledTaskId"]; readonly senderThreadId?: OrchestrationV2ConversationMessage["senderThreadId"]; + readonly idleCompaction?: OrchestrationV2ConversationMessage["idleCompaction"]; readonly delegatedCompletion?: OrchestrationV2ConversationMessage["delegatedCompletion"]; readonly forceRestart: boolean; }) { @@ -4728,6 +4824,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: input.messageId, threadId: input.command.threadId, runId: messageInput.runId, @@ -4762,6 +4859,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: input.scheduledTaskId }), ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + ...(input.idleCompaction === undefined ? {} : { idleCompaction: input.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: input.messageId }), threadId: input.command.threadId, runId: messageInput.runId, @@ -5709,6 +5807,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), forceRestart: dispatchMode.type === "restart_active", }); return; @@ -5737,14 +5836,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode.type === "start_immediately" || dispatchMode.type === "queue_after_active"; const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun; - // A message the user sends from a client means they are back, so the - // queue a stop or a restart held runs again, in its order, rather than - // waiting for a Resume. - // Continuations and scheduled tasks the server sends for them are not - // them coming back. Starting the queue still re-holds it for a failed - // provider or a usage limit; a queue held after worktree setup failed - // keeps its hold until a worktree exists, even once later messages have - // run, since its messages would start in the project checkout. + // Client messages resume held queues; server continuations and scheduled tasks do not. + // Provider failure or usage limits re-hold the queue. Failed worktree setup keeps it held + // until a worktree exists, so queued messages cannot start in the project checkout. const worktreeSetupUnresolved = projection.thread.worktreePath === null && projection.runs.some( @@ -5944,6 +6038,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -6284,6 +6379,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -6317,6 +6413,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -6994,6 +7091,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: command.messageId, threadId: command.threadId, runId, @@ -7015,6 +7113,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { scheduledTaskId: command.scheduledTaskId }), ...(command.senderThreadId === undefined ? {} : { senderThreadId: command.senderThreadId }), + ...(command.idleCompaction === undefined ? {} : { idleCompaction: command.idleCompaction }), id: idAllocator.derive.userTurnItem({ messageId: command.messageId }), threadId: command.threadId, runId, @@ -7738,9 +7837,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) .at(-1); if (message?.runId == null) return; - // Only a message that starts a run or a new attempt can own a task. A - // steer joins the running attempt, which finalizes its run from the - // snapshot taken when it started, so a task bound to it would never settle. + // Only new attempts can own tasks; steers join an existing snapshot that cannot settle newly bound tasks. const startsAttempt = pending.some( (event) => (event.type === "run.created" && event.payload.id === message.runId) || @@ -7882,11 +7979,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // The wait path uses this when its blocking window ends without a terminal - // (timeout), so a child that later terminalizes mid-parent-turn still wakes - // the parent. Runs under the parent thread's dispatch lock, which is also - // what finalize takes for its parent-side writes, so the two never interleave - // on this row. + // After blocking waits time out, enable later completions to wake the parent. + // Hold the parent dispatch lock shared with finalization to serialize full-row writes. const dispatchDelegatedTaskWakePolicy = Effect.fn( "orchestrationV2.dispatch.delegatedTaskWakePolicy", )(function* ( @@ -7928,8 +8022,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completionWake: command.completionWake, updatedAt: now, }; - // When that run is not live, finalize already offered and a second offer would wake the - // parent twice. + // Finalization already offered for ended parent runs; another offer would duplicate the wake. const parentRun = task.runId === null ? undefined @@ -8169,6 +8262,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio label: command.label, reason: command.reason, ...(command.placeholder === undefined ? {} : { placeholder: command.placeholder }), + ...(command.delivery === undefined ? {} : { delivery: command.delivery }), secretStatus: command.secretStatus, }; yield* emit( @@ -8503,6 +8597,65 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + /** + * Respond resolves the request before delivery, and a failed delivery cannot + * tell whether the provider is still waiting, so the answer stays recorded and + * the timeline shows the failure instead of a run spinning with no question. + */ + const dispatchRuntimeRequestDeliveryFail = Effect.fn( + "Orchestrator.dispatchRuntimeRequestDeliveryFail", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "runtime-request.delivery.fail" } + >, + events: Ref.Ref>, + ) { + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe(mapDispatchError(command)); + if (thread.deletedAt !== null) return; + const node = (yield* projectionStore + .getRuntimeResponseContext(command.threadId, command.requestId) + .pipe(mapDispatchError(command))).node; + if (node === undefined) return; + const now = yield* DateTime.now; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + ...(node.runId === null ? {} : { runId: node.runId }), + nodeId: node.id, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`turn-item:runtime-request-delivery-failure:${command.requestId}`), + threadId: command.threadId, + runId: node.runId, + nodeId: node.id, + providerThreadId: node.providerThreadId, + providerTurnId: node.providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal({ thread }), + status: "failed", + title: "Answer delivery not confirmed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure: { + class: "transport_error", + message: RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE, + code: null, + retryable: null, + }, + }, + }); + }); + const dispatchThreadUserInputDismiss = Effect.fn("Orchestrator.dispatchThreadUserInputDismiss")( function* ( command: Extract, @@ -8677,6 +8830,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedMessage.senderThreadId === undefined ? {} : { senderThreadId: queuedMessage.senderThreadId }), + ...(queuedMessage.idleCompaction === undefined + ? {} + : { idleCompaction: queuedMessage.idleCompaction }), forceRestart: false, }); }); @@ -8856,6 +9012,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, }); } + const guest = queuedMessage?.context?.guest; + if (guest !== undefined) { + const thread = guestShareAfterCancelledSend(projection.thread, guest, now); + if (thread !== projection.thread) { + yield* emitEvent({ + type: "thread.guest-share-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: now, + payload: thread, + }); + } + } }); const dispatchQueuedRunEdit = Effect.fn("Orchestrator.dispatchQueuedRunEdit")(function* ( @@ -8903,6 +9072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "Automatic completion deliveries cannot be edited.", }); } + // The agent text and the shown text of a guest message must stay the guest's words. + if (queuedMessage.context?.guest !== undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A guest's message cannot be edited. Cancel it instead.", + }); + } const queuedTurnItem = projection.turnItems.find( (candidate) => candidate.type === "user_message" && candidate.messageId === queuedMessage.id, ); @@ -8918,13 +9095,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: { + payload: withoutGuestAttribution({ ...queuedMessage, text: command.text, ...editedAttachments, ...(command.context ? { context: command.context } : {}), updatedAt: now, - }, + }), }); if (queuedTurnItem !== undefined && queuedTurnItem.type === "user_message") { yield* emitEvent({ @@ -8934,13 +9111,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(queuedRun.rootNodeId === null ? {} : { nodeId: queuedRun.rootNodeId }), providerInstanceId: queuedRun.providerInstanceId, occurredAt: now, - payload: { + payload: withoutGuestAttribution({ ...queuedTurnItem, text: command.text, ...editedAttachments, ...(command.context ? { context: command.context } : {}), updatedAt: now, - }, + }), }); } }); @@ -9808,11 +9985,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); - /** - * Its agent can call tools - * until the provider stops it, and a slow call can land after that, but nothing it starts - * may outlive the Stop. A restart continuation of that run must not start either. - */ + /** Late tool calls and restart continuations must not outlive Stop. */ const stopReachedRun = ( command: OrchestrationV2ServerCommand, threadId: ThreadId, @@ -9880,12 +10053,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - /** - * What Stop holds besides the run it interrupts: durable queued messages wait for the - * user, buffered provider wakes are cancelled, and the thread's pull request watches end. - * Every wake its delegated tasks still owe is dropped, since Stop stops those tasks too. - * `cohortRunIds` are dropped even when they owe nothing, like a plain interrupt's. - * `keepBackgroundWork` holds the queue only: watches, delegated tasks, and their wakes stay. + /** Stop holds queued messages, cancels wakes and watches, and stops delegated tasks. + * Drop cohort wakes even when nothing is owed. With `keepBackgroundWork`, only + * hold the queue; preserve watches, delegated tasks, and their wakes. */ const holdStoppedThread = Effect.fn("Orchestrator.holdStoppedThread")(function* (input: { readonly command: Extract< @@ -10190,12 +10360,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); const run = projection.runs.find((candidate) => candidate.id === command.runId); const subagent = projection.subagents.find((candidate) => candidate.id === command.subagentId); - // A user Stop cancels an app-owned task here when nothing else can settle it: no child run was - // ever bound to it and it is not the child's original task (#19002). This holds whatever else - // the child is doing. A task with a bound run, live or ended, keeps its settlement path, since - // its result may still be on the way, and the child stop the client sends next interrupts the - // run. A task that already ended is written again, so a client holding a stale copy, such as - // one cached before a repair, shows it ended. + // User Stop cancels app-owned tasks with no bound child run unless they are the child's + // original task, regardless of other child work. Bound runs keep their settlement path + // because results may still arrive; the client's next child stop interrupts the run. + // Rewrite ended tasks so clients with stale copies see them end. if ( subagent?.origin === "app_owned" && subagent.childThreadId !== null && @@ -10443,11 +10611,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); const runOrdinalOf = (item: (typeof projection.turnItems)[number]) => item.runId === null ? -1 : (runOrdinals.get(item.runId) ?? -1); - // Each provider thread is interrupted at its latest pending work: that - // turn's run bounds the settle follow-up, which must cover all of the - // thread's work. The target comes from the item's provider turn, since - // a native subagent item names its own provider thread but its - // parent's turn; interrupting the parent's turn reaches the subagent. + // Interrupt each provider thread at its latest pending turn so settlement covers all its work. + // Use the item's provider turn: native subagent items name the child thread but the parent turn, + // and interrupting the parent turn reaches the subagent. const latestTurnByProviderThread = new Map< OrchestrationV2ProviderThread["id"], { readonly turn: (typeof projection.providerTurns)[number]; readonly runOrdinal: number } @@ -11217,9 +11383,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio offer: false, }; } - // settled_only defers to a blocking delegate_task wait, which lives only as - // long as the turn that spawned the task. Once that turn is over (a restart - // continues it as a new run), nothing else delivers the result. + // settled_only relies on a blocking wait that ends with the spawning turn; later results need another delivery path. if ( (input.task.completionWake ?? "settled_only") === "settled_only" && hasLiveRun({ runs: [input.parentRun] }) @@ -11293,10 +11457,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } if (deliveryRun !== undefined) { - // The terminal-run listener owns reconciliation of a completed wake. - // A sibling that wins the parent lock first remains pending for the - // successor that listener reserves, rather than creating a competing - // delivery. + // The terminal-run listener reserves successor wakes; keep racing siblings pending to avoid competing deliveries. return { task: { ...input.updatedTask, @@ -11372,12 +11533,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); /** - * Every mutation here targets the PARENT thread, so callers must hold the - * parent thread's dispatch lock rather than the child's: the - * delegated_task.wake-policy handler rewrites the same subagent row under - * that lock with a full-row payload, and unserialized writers clobber each - * other (stale policy on the terminal row, or a terminal row regressed to - * running). + * Callers must hold the parent thread's dispatch lock: wake-policy updates the same + * subagent row with full-row payloads, so unserialized writers clobber policy or terminal status. */ const finalizeAppOwnedSubagentTask = Effect.fn("Orchestrator.finalizeAppOwnedSubagentTask")( function* ( @@ -12064,10 +12221,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* dispatchThreadVisit(command, events); break; case "thread.auto-settle": { - // Automatic settlement (#8600): the sweep evaluated a shell snapshot, - // so re-check against the live thread before settling. Any change - // after the snapshot — or any explicit override, including the - // un-settle button's "active" — wins over the sweep. + // Recheck live state; changes and explicit overrides take precedence over the sweep snapshot. const thread = yield* projectionStore .getThread(command.threadId) .pipe( @@ -12162,13 +12316,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.link-watch.wake": yield* dispatchLinkWatchWake(command, events, effects); break; + case "thread.guest-share.open": + case "thread.guest-share.redeem": + case "thread.guest-share.close": + case "thread.guest-message.submit": + case "thread.guest-message.dismiss": + yield* dispatchGuestShare(command, events, effects); + break; case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; case "message.dispatch": { - // The provider owns a native subagent's conversation, so a sent - // message has nowhere to go. Answers to a subagent's questions never - // target it either: adapters ask them on the top-level parent thread. + // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent. const thread = yield* projectionStore .getThread(command.threadId) .pipe( @@ -12182,8 +12341,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, }); } - yield* dispatchMessage(command, events, effects); - yield* recordSubagentFollowup(command, events).pipe(mapDispatchError(command)); + const guestSend = + command.guestMessageId === undefined + ? undefined + : yield* emitGuestMessageSent(command, command.guestMessageId, thread, events); + const dispatched = + guestSend === undefined + ? withoutGuestAttribution(command) + : { + ...command, + text: guestSend.text, + context: { version: 1 as const, records: [], guest: guestSend.guest }, + }; + yield* dispatchMessage(dispatched, events, effects); + yield* recordSubagentFollowup(dispatched, events).pipe(mapDispatchError(command)); break; } case "thread.usage-limit.resume-now": { @@ -12380,6 +12551,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "checkpoint.rollback.fail": yield* dispatchCheckpointRollbackFail(command, events); break; + case "runtime-request.delivery.fail": + yield* dispatchRuntimeRequestDeliveryFail(command, events); + break; case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events, effects); break; @@ -12476,15 +12650,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: receipt.resultSequence, storedEvents, + replayed: true, } satisfies OrchestratorV2DispatchResult; } - // A limited sender checked these modes before dispatching; the thread's - // user may have raised them since, and only here can they not change. + // Recheck under the lock; the user may have raised modes since sender preflight. const limit = yield* DispatchModeLimit; if (limit !== undefined) { - // A fork or merge-back source is not under this lock: its dispatch - // checks the copy it reads instead. + // Fork and merge-back sources are unlocked; validate the modes copied by their dispatch. const threadId = commandThreadId(command); const shell = yield* projectionStore .getThreadShell(threadId) @@ -12499,7 +12672,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.stop" || command.type === "subagent.stop" || // Each bound run re-files its thread; an unchanged filing commits nothing. - command.type === "thread.scheduled-task.set" + command.type === "thread.scheduled-task.set" || + // A second Stop sharing, or one after the share already ended, is not an error. + command.type === "thread.guest-share.close" ? Effect.succeed(planned) : Effect.fail( new OrchestratorDispatchError({ @@ -12550,9 +12725,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (plan.events.length === 0 && plan.effects.length === 0) { // A settle that ended nothing still records its receipt: a replayed Stop // effect then finds it instead of settling work that appeared since. - const resultSequence = yield* Effect.gen(function* () { + const { resultSequence, inserted } = yield* Effect.gen(function* () { const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); - yield* commandReceipts.insertIfAbsent({ + const inserted = yield* commandReceipts.insertIfAbsent({ commandId: command.commandId, threadId: commandThreadId(command), commandType: command.type, @@ -12561,7 +12736,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio status: "accepted", error: null, }); - return sequence; + return { resultSequence: sequence, inserted }; }).pipe( Effect.mapError( (cause) => @@ -12572,7 +12747,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - return { sequence: resultSequence, storedEvents: [] } satisfies OrchestratorV2DispatchResult; + return { + sequence: resultSequence, + storedEvents: [], + ...(inserted ? {} : { replayed: true as const }), + } satisfies OrchestratorV2DispatchResult; } const acceptedAt = plan.events.at(-1)?.occurredAt ?? (yield* DateTime.now); const committed = yield* eventSink @@ -12623,6 +12802,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { sequence: committed.receipt.resultSequence, storedEvents: committed.storedEvents, + ...(committed.committed ? {} : { replayed: true as const }), } satisfies OrchestratorV2DispatchResult; }); @@ -12653,12 +12833,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (stored.event.type !== "run.updated") return; const threadId = stored.event.threadId; const run = stored.event.payload; - // finalize writes the parent thread and startNextQueuedRun writes this - // thread, so each takes its own thread's lock, sequentially and never - // nested: dispatchDelegatedTaskRequest already writes child events - // while holding the parent lock, so nesting the parent lock inside the - // child lock here would invert that order, and the keyed executor's - // semaphores are neither reentrant nor deadlock-aware. + // Take parent and child locks sequentially, never nested: delegation holds the parent + // while writing child events, so nesting parent inside child would invert the order. + // The keyed semaphores are neither reentrant nor deadlock-aware. if (delegatedTaskTerminalStatus(run.status) !== null) { yield* threadDispatch.withLock( threadId, @@ -12698,12 +12875,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); - // Historical terminal events are already represented by the projections - // below. Replaying the full event table on every server start delays live - // queue promotion in proportion to the lifetime size of the database. + // Projections already contain historical terminals; replaying them delays startup queue promotion with database size. const terminalEventsAfterSequence = yield* eventSink.latestSequence().pipe(Effect.orDie); - // Queue promotion can wait on a provider or a thread lock. Subscribe to run - // updates before buffering so that wait never retains unrelated tool bodies. + // Filter run updates before buffering so blocked queue promotion does not retain unrelated tool bodies. yield* eventSink .stream({ afterSequence: terminalEventsAfterSequence, eventType: "run.updated" }) .pipe( @@ -12726,7 +12900,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A child whose run ends while its background tasks or its own subagents still run has no // result yet. When that work settles there is no run update to finalize it, so these events - // do it (#17512). A background roster counts once it empties after it was seen non-empty. + // do it. A background roster counts once it empties after it was seen non-empty. const providerThreadsWithBackgroundWork = new Set(); const settlesChildWork = (stored: OrchestrationV2StoredEvent) => { const { event } = stored; @@ -12810,10 +12984,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId, Effect.gen(function* () { const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); - // Only the run that consumed a cohort's current delivery - // message can settle it. A long-lived parent keeps every - // past delivery run, and finalizing each of them would - // reload the thread once per historical delivery. + // Only the current delivery's run can settle its cohort; revisiting historical delivery runs repeatedly reloads the thread. const openDeliveryMessageIds = new Set( projection.runs.flatMap((run) => run.delegatedCompletion?.delivery == null @@ -13003,6 +13174,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), listActiveSubagentThreadIds: () => projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)), + listOlderSettledThreads: (query) => + projectionStore.listOlderSettledThreads(query).pipe(Effect.mapError(shellProjectionError)), readShellSnapshot: (options) => projectionStore .readShellSnapshot(options) @@ -13014,6 +13187,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore .getThreadShell(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + findGuestShareThreadId: (shareId) => + projectionStore.findGuestShareThreadId(shareId).pipe(Effect.mapError(shellProjectionError)), getThreadEventSequence: (threadId) => eventSink .latestSequence({ threadId }) @@ -13035,10 +13210,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ), - // Live tail only. eventSink.stream() with no cursor replays the whole - // store from genesis first; domain-event subscribers (the awareness relay) - // react to new activity, and startup replay made them grind through the - // entire event history doing per-event work after every boot. + // Start at the latest sequence; awareness needs no historical replay. streamDomainEvents: Stream.unwrap( eventSink .latestSequence() @@ -13078,119 +13250,3 @@ export const layer: Layer.Layer< > = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe( Layer.provide(ThreadCommandExecutor.layer), ); - -const layerUnavailable: Layer.Layer = Layer.succeed( - OrchestratorV2, - OrchestratorV2.of({ - resumeQueuedRuns: Effect.fail( - new OrchestratorDispatchError({ - commandId: CommandId.make("command:system:resume-queued-runs"), - commandType: "message.dispatch", - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - recoverDelegatedTasks: Effect.void, - recoverDelegatedTask: () => Effect.void, - delegatedTaskResultPending: () => Effect.succeed(false), - restartContinuationPending: () => Effect.succeed(false), - awaitRestartContinuation: () => Effect.void, - autoArmLimitRecovery: (input) => - Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })), - dispatch: (command) => - Effect.fail( - new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - searchThreadStream: (input) => - Stream.fail(new OrchestratorProjectionError({ threadId: input.threadId })), - searchThread: (input) => - Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })), - getThreadHistoryPage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), - getThreadProjection: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getCheckpointContext: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadSnapshot: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadSnapshotWindow: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getShellSnapshot: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId: ThreadId.make("thread:shell"), - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - listActiveSubagentThreadIds: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId: ThreadId.make("thread:shell"), - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - readShellSnapshot: () => - Effect.fail( - new OrchestratorProjectionError({ - threadId: ThreadId.make("thread:shell"), - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadShell: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - getThreadEventSequence: (threadId) => - Effect.fail( - new OrchestratorProjectionError({ - threadId, - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamStoredEvents: Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamStoredEventsFrom: () => - Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - streamDomainEvents: Stream.fail( - new OrchestratorDomainEventStreamError({ - cause: "Orchestration V2 live runtime is not configured.", - }), - ), - } satisfies OrchestratorV2Shape), -); diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index f3814f42ea7ef..674a04783e347 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -104,10 +104,7 @@ export interface ProjectCommandState { const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); -/** - * The caller reads `state` under the project's lock and commits the planned - * event. - */ +/** Read state and commit the planned event under the project lock. */ export function planProjectCommand(input: { readonly command: ProjectCommand; readonly state: ProjectCommandState; @@ -180,8 +177,7 @@ export function planProjectCommand(input: { return invariant("Project monograms must contain at most two characters."); } if (command.scripts !== undefined) { - // Persisted IDs predate shortcut validation. Let users edit or remove them - // without allowing another invalid ID to enter the project. + // Allow editing or removing persisted invalid script ids, while rejecting new invalid ids. const existingIds = new Set(project.scripts.map((script) => script.id)); for (const script of command.scripts) { if (!existingIds.has(script.id) && !isScriptRunCommand(`script.${script.id}.run`)) { diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index aa7b27de6048b..335522c7542af 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -96,7 +96,7 @@ export class ProjectStoreV2 extends Context.Service< } >()("t3/orchestration-v2/ProjectStore/ProjectStoreV2") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const encodeRow = Schema.encodeEffect(ProjectDbRow); diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index 34e19b25dec0d..9ebceb4e6d8cb 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -196,9 +196,7 @@ export const layer: Layer.Layer< Effect.mapError((cause) => new ProjectionMaintenanceError({ operation, cause })), ); - // thread.created stays out — verify derives the expected thread set from - // it, and it anchors replay ordering. thread.deleted stays out too: clients - // and waitForThread switch on the event type, not just the payload. + // Keep thread.created as the verification and replay anchor; clients recognize deletion by thread.deleted event type. const SUPERSEDABLE_THREAD_EVENT_TYPES = [ "thread.archived", "thread.unarchived", @@ -219,11 +217,9 @@ export const layer: Layer.Layer< "thread.interaction-mode-updated", "thread.model-selection-updated", "thread.provider-switched", + "thread.guest-share-updated", ]; - // Read-state events carry the whole thread too, but client reducers apply - // them without bumping updatedAt. They supersede only each other: a newer - // visit must not delete the rename that carries the activity timestamp, - // while any newer activity-bearing event supersedes both groups. + // Read-state events preserve updatedAt and supersede only each other. Activity events supersede both groups. const READ_STATE_THREAD_EVENT_TYPES = ["thread.visited", "thread.marked-unread"]; const supersedableThreadEventTypes = new Set(SUPERSEDABLE_THREAD_EVENT_TYPES); @@ -232,16 +228,8 @@ export const layer: Layer.Layer< // Small pages bound work on the reader worker and synchronous fallback. const COMPACTION_PAGE_SIZE = 50; - /** - * Scan newest first to retain the newest state for each entity. Keyed - * entities (messages, nodes, PR links) also keep their oldest event: client - * reducers append an entity where they first see it, so a replay from any - * cursor must either carry that first event or find the entity already - * present. Only the events in between are deleted. - * Page every event, including non-candidates: filtering before LIMIT could - * still scan the entire history when superseded events are sparse. - * turn-item.updated stays intact because replay assigns positions on first write. - */ + /** Retain newest and oldest keyed events to preserve client insertion order; page all events to bound sparse scans. + * Keep turn-item.updated because replay assigns positions on first write. */ const compactEventStore = Effect.gen(function* () { const bounds = yield* sql<{ readonly event_sequence: number; @@ -255,8 +243,7 @@ export const layer: Layer.Layer< let throughReceiptRowId = bounds[0]?.receipt_row_id ?? 0; const retainedThreadIds = new Set(); const retainedReadStateThreadIds = new Set(); - // Per key: the lowest sequence seen so far after the newest. It is - // deleted once an even older event turns up and kept otherwise. + // Retain each key's oldest candidate, deleting it only when an older event appears. const oldestCandidateByEntityKey = new Map(); let deletedEventCount = 0; let deletedReceiptCount = 0; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index fcadd8036b924..a8dedfeec2805 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -40,8 +40,10 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import { buildBoundedThreadProjection, decodeThreadHistoryCursor, + encodeThreadHistoryCursor, selectHistoryPageFromCursor, THREAD_HISTORY_PAGE_POLICY, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; const layerTest = Layer.mergeAll( @@ -409,7 +411,6 @@ it.effect.each([ }); const providerThreadId = ProviderThreadId.make("provider-thread:duplicate-owner"); const nativeTaskId = "native-task:duplicate-provider-thread-owner"; - // The latest upsert moves the row off the later-created thread. for (const [appThreadId, owner] of [ [otherThreadId, otherInstanceId], [threadId, providerInstanceId], @@ -497,7 +498,6 @@ it.effect.each([ modelSelection: { ...modelSelection, instanceId: otherInstanceId }, }, }); - // The other thread's item reads its owner from a provider thread stored on another thread. assert.deepEqual((yield* store.getThreadProjection(otherThreadId)).providerThreads, []); const assertOwners = Effect.fnUntraced(function* ( targetThreadId: ThreadId, @@ -1105,6 +1105,189 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio return { projectId, activeThreadId, forkThreadId, otherThreadId }; }); +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)("%s shells read a subagent family and a worktree's threads", ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const projectId = ProjectId.make("project:family"); + const worktreePath = "/repo/.t3/worktrees/family"; + const id = (name: string) => ThreadId.make(`thread:family:${name}`); + const payloadOf = (input: { + readonly name: string; + readonly parent?: string; + readonly root?: string; + readonly worktreePath?: string; + readonly archived?: boolean; + }) => + ({ + createdBy: "user", + creationSource: input.parent === undefined ? "web" : "mcp", + id: id(input.name), + projectId, + title: input.name, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: input.worktreePath ?? null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.parent === undefined ? null : id(input.parent), + relationshipToParent: input.parent === undefined ? null : "subagent", + rootThreadId: id(input.root ?? input.name), + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }) as const; + const createThread = (input: Parameters[0]) => + store.apply({ + id: EventId.make(`event:family:${input.name}`), + type: "thread.created", + threadId: id(input.name), + occurredAt: now, + payload: payloadOf(input), + }); + yield* createThread({ name: "root", worktreePath }); + yield* createThread({ name: "child", parent: "root", root: "root", worktreePath }); + yield* createThread({ name: "grandchild", parent: "child", root: "root" }); + yield* createThread({ name: "archived-child", parent: "root", root: "root", archived: true }); + yield* createThread({ name: "other", worktreePath: "/repo" }); + yield* createThread({ name: "other-child", parent: "other", root: "other", worktreePath }); + + const family = (threadId: ThreadId) => + store + .getShellSnapshot({ location: "active", familyOf: threadId }) + .pipe(Effect.map((snapshot) => snapshot.threads.map((thread) => thread.id))); + // Any member, or the root itself, names the same active tree, root included. + const tree = [id("root"), id("child"), id("grandchild")]; + assert.sameMembers([...(yield* family(id("root")))], tree); + assert.sameMembers([...(yield* family(id("grandchild")))], tree); + assert.deepEqual([...(yield* family(id("missing")))], []); + + // Includes another root's idle child on the same path, which a shell may leave out. + const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + assert.sameMembers( + onWorktree.threads.map((thread) => thread.id), + [id("root"), id("child"), id("other-child")], + ); + // A fork keeps its deleted source's root, so the deleted root still names the live tree. + yield* store.apply({ + id: EventId.make("event:family:root-deleted"), + type: "thread.deleted", + threadId: id("root"), + occurredAt: now, + payload: { ...payloadOf({ name: "root", worktreePath }), deletedAt: now }, + }); + assert.sameMembers([...(yield* family(id("root")))], [id("child"), id("grandchild")]); + }).pipe(Effect.provide(layer)), +); + +it.effect.each([ + ["memory", ProjectionStore.layerMemory], + ["SQL", layerTest], +] as const)("%s list older settled threads below a cursor", ([, layer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const id = (name: string) => ThreadId.make(`thread:older:${name}`); + const at = (day: number) => DateTime.makeUnsafe(Date.UTC(2026, 9, day)); + const createThread = (input: { + readonly name: string; + readonly settledDay: number | null; + readonly project?: string; + readonly subagent?: boolean; + readonly archived?: boolean; + readonly snoozedUntilDay?: number; + }) => + store.apply({ + id: EventId.make(`event:older:${input.name}`), + type: "thread.created", + threadId: id(input.name), + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: id(input.name), + projectId: ProjectId.make(input.project ?? "project:a"), + title: input.name, + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: input.subagent === true ? id("b") : null, + relationshipToParent: input.subagent === true ? "subagent" : null, + rootThreadId: input.subagent === true ? id("b") : id(input.name), + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: input.archived === true ? now : null, + settledOverride: input.settledDay === null ? null : "settled", + settledAt: input.settledDay === null ? null : at(input.settledDay), + snoozedUntil: input.snoozedUntilDay === undefined ? null : at(input.snoozedUntilDay), + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* createThread({ name: "a", settledDay: 5 }); + yield* createThread({ name: "b", settledDay: 4 }); + yield* createThread({ name: "c", settledDay: 4 }); + yield* createThread({ name: "d", settledDay: 3, project: "project:b" }); + yield* createThread({ name: "snoozed", settledDay: 2, snoozedUntilDay: 30 }); + yield* createThread({ name: "recent", settledDay: 20 }); + yield* createThread({ name: "unsettled", settledDay: null }); + yield* createThread({ name: "child", settledDay: 1, subagent: true }); + yield* createThread({ name: "archived", settledDay: 1, archived: true }); + + const list = ( + before: { readonly settledAt: string; readonly threadId?: ThreadId }, + limit: number, + projectIds?: ReadonlyArray, + ) => store.listOlderSettledThreads({ before, projectIds, limit }); + const before = { settledAt: DateTime.formatIso(at(10)) }; + const first = yield* list(before, 2); + assert.deepEqual(first, { threadIds: [id("a"), id("b")], counts: [] }); + assert.sameDeepMembers( + [...(yield* list(before, 0)).counts], + [ + { projectId: ProjectId.make("project:a"), count: 4 }, + { projectId: ProjectId.make("project:b"), count: 1 }, + ], + ); + // A cursor without an id is strict, as the snapshot's cut is: the day-4 tie is not counted. + assert.sameDeepMembers( + [...(yield* list({ settledAt: DateTime.formatIso(at(4)) }, 0)).counts], + [ + { projectId: ProjectId.make("project:a"), count: 1 }, + { projectId: ProjectId.make("project:b"), count: 1 }, + ], + ); + // The cursor is the last row: the tie on day 4 continues by id. + const next = yield* list({ settledAt: DateTime.formatIso(at(4)), threadId: id("b") }, 10); + assert.deepEqual(next.threadIds, [id("c"), id("d"), id("snoozed")]); + assert.deepEqual(yield* list(before, 25, []), { threadIds: [], counts: [] }); + const scoped = yield* list(before, 0, [ProjectId.make("project:b")]); + assert.deepEqual(scoped, { + threadIds: [], + counts: [{ projectId: ProjectId.make("project:b"), count: 1 }], + }); + }).pipe(Effect.provide(layer)), +); + it.effect("lists only active subagent IDs without loading shell data", () => Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; @@ -1529,6 +1712,126 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { [baselineId], ); } + const humanWindow = () => + projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: 77, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, + }); + assert.lengthOf( + (yield* humanWindow()).projection.turnItems, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, + ); + // With the newest user turn 1,000 rows back, the window pages from inside it. + const setNewestTurnType = (type: "command_execution" | "user_message") => sql` + UPDATE orchestration_v2_projection_turn_items + SET type = ${type}, payload_json = json_set(payload_json, '$.type', ${type}) + WHERE thread_id = ${threadId} AND ordinal = 1000 + `; + yield* setNewestTurnType("command_execution"); + assert.lengthOf((yield* humanWindow()).projection.turnItems, 77); + yield* setNewestTurnType("user_message"); + + // Fewer than 75 rows still need a byte pass after materializing wire payloads. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'assistant_message', payload_json = json_set(payload_json, + '$.type', 'assistant_message', '$.text', ${"x".repeat(40_000)}, + '$.messageId', turn_item_id, '$.streaming', json('false')) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 2 AND 70 + `; + const byteCursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:71"), + position: 70, + }); + const bytePage = yield* projectionStore.getThreadHistoryPage(threadId, byteCursor); + assert.isAbove(bytePage.items.length, 0); + assert.isBelow(bytePage.items.length, 70); + assert.isAtMost( + Buffer.byteLength(JSON.stringify(bytePage.items), "utf8"), + THREAD_HISTORY_PAGE_POLICY.maxEncodedBytes, + ); + const byteIds = bytePage.items.map((row) => String(row.sourceItemId)); + let byteNext = bytePage.nextCursor; + for (let pages = 0; byteNext !== null; pages += 1) { + assert.isBelow(pages, 70); + const page = yield* projectionStore.getThreadHistoryPage(threadId, byteNext); + assert.isAbove(page.items.length, 0); + byteIds.unshift(...page.items.map((row) => String(row.sourceItemId))); + byteNext = page.nextCursor; + } + assert.deepEqual( + byteIds, + Array.from({ length: 70 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), + ); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal BETWEEN 2 AND 70 + `; + + // A targeted page that needs more than 20 human turns must still reach its target + // after the byte pass re-bounds it. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', payload_json = json_set(payload_json, + '$.type', 'user_message', '$.createdBy', 'user', '$.creationSource', 'web', + '$.inputIntent', 'turn_start', '$.messageId', turn_item_id, '$.text', 'Turn', + '$.attachments', json('[]')) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 600 AND 624 + `; + const targetedPage = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:625"), + position: 624, + }), + "turn-item:bounded-sql-history:600", + ); + assert.strictEqual( + String(targetedPage.items[0]?.sourceItemId), + "turn-item:bounded-sql-history:600", + ); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal BETWEEN 600 AND 624 + `; + + // A turn split by the row budget keeps an interrupt result's request on the same page. + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:500', + type = CASE ordinal WHEN 500 THEN 'run_interrupt_request' ELSE 'run_interrupt_result' END, + payload_json = json_set(payload_json, + '$.runId', 'run:bounded-sql-history:500', '$.message', 'Stop', + '$.type', CASE ordinal WHEN 500 THEN 'run_interrupt_request' ELSE 'run_interrupt_result' END) + WHERE thread_id = ${threadId} AND ordinal IN (500, 580) + `; + const interruptPage = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:590"), + position: 589, + }), + ); + const interruptIds = interruptPage.items.map((row) => String(row.sourceItemId)); + assert.include(interruptIds, "turn-item:bounded-sql-history:580"); + assert.include(interruptIds, "turn-item:bounded-sql-history:500"); + assert.notInclude(interruptIds, "turn-item:bounded-sql-history:501"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:' || ordinal, type = 'command_execution', + payload_json = json_set(payload_json, + '$.runId', 'run:bounded-sql-history:' || ordinal, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal IN (500, 580) + `; + const retainedRequestPlan = yield* sql<{ readonly detail: string }>` EXPLAIN QUERY PLAN WITH selected AS ( @@ -1571,9 +1874,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, + userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }); const initialPage = buildBoundedThreadProjection({ - policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined }, projection: initialSnapshot.projection, snapshotSequence: initialSnapshot.snapshotSequence, }); @@ -1586,10 +1889,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const anchorItemId = TurnItemId.make(decodeThreadHistoryCursor(cursor).si); const snapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, + userTurnLimit: 20, anchorItemId, }); + assert.isAtMost( + snapshot.projection.visibleTurnItems.length, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, + ); const page = selectHistoryPageFromCursor({ - policy: { ...THREAD_HISTORY_PAGE_POLICY, maxUserTurns: undefined }, items: snapshot.projection.visibleTurnItems, cursor, snapshotSequence: snapshot.snapshotSequence, @@ -1599,7 +1906,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { cursor = page.nextCursor; } - assert.isAtLeast(pageCount, 4); + assert.isAtLeast(pageCount, 3); assert.lengthOf(loadedIds, 1_000); assert.strictEqual(new Set(loadedIds).size, 1_000); assert.deepEqual( @@ -2879,6 +3186,150 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect.each([ + ["T3 Code's idle compaction", "agent", "server", undefined, true, "/compact"], + ["compacting on return", "user", "web", "onReturn", true, "/compact"], + ["a typed /compact", "user", "mobile", undefined, false, "/compact"], + ["a normalized idle command", "agent", "server", undefined, true, " \t\u00a0/COMPACT\u3000\n"], + ["an ordinary prompt with idle metadata", "user", "web", "onReturn", false, "Continue"], + ] as const)( + "marks the shell's latest run silent only for %s", + ([name, createdBy, creationSource, idleCompaction, silent, text]) => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const key = name.replaceAll(/\W+/g, "-"); + const threadId = ThreadId.make(`thread:projection-shell-silent:${key}`); + const runId = RunId.make(`run:projection-shell-silent:${key}`); + const messageId = MessageId.make(`message:projection-shell-silent:${key}`); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:thread`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:projection-shell-silent"), + title: "Idle compaction", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:message`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + createdBy, + creationSource, + ...(idleCompaction === undefined ? {} : { idleCompaction }), + id: messageId, + threadId, + runId, + nodeId: null, + role: "user", + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:run`), + type: "run.created", + threadId, + runId, + nodeId: NodeId.make(`node:projection-shell-silent:${key}`), + driver, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + + const sqlShell = (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ); + assert.equal(sqlShell?.latestRunSilent, silent); + assert.equal(memoryShell.latestRunSilent, silent); + + // Claude can finish the run successfully even when its /compact failed. + yield* projectionStore.apply({ + id: EventId.make(`event:projection-shell-silent:${key}:failure`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:projection-shell-silent:${key}:compaction`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "compaction", + driver, + status: "failed", + title: "Context compaction failed", + summary: "Not enough messages to compact.", + startedAt: now, + completedAt: now, + updatedAt: now, + }, + }); + assert.equal( + (yield* projectionStore.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )?.latestRunSilent, + false, + ); + assert.equal( + ProjectionStore.threadShellFromProjection( + yield* projectionStore.getThreadProjection(threadId), + ).latestRunSilent, + false, + ); + }), + ); + it.effect("only exposes interruptible runs through the shell activeRunId", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -5684,6 +6135,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { taskId: "pull-request-watch:github.com/pingdotgg/t3code#7", description: "Watching pull request #7", kind: "monitor", + awaited: true, }, ]); assert.equal(yield* phase, "running"); @@ -5716,7 +6168,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { lastRunOrdinal: 1, handoffIds: [], forkedFrom: null, - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" as const }], + pendingBackgroundTasks: [ + { taskId: "watch", kind: "monitor" as const, awaited: true as const }, + ], createdAt: completedAt, updatedAt: completedAt, }; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index bb0cb507fe4fb..ed4072b8ceb60 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,3 +1,4 @@ +import { pendingGuestMessageCount } from "@t3tools/contracts"; import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; @@ -25,6 +26,8 @@ import type { PlanId, OrchestrationV2ConversationMessage, OrchestrationV2DomainEvent, + OrchestrationV2OlderSettledCount, + OrchestrationV2OlderSettledCursor, OrchestrationV2ProjectedTurnItem, OrchestrationV2ProviderThread, OrchestrationV2ProviderTurn, @@ -35,13 +38,15 @@ import type { OrchestrationV2ThreadShell, OrchestrationV2ThreadProjection, OrchestrationV2TurnItem, - ProjectId, + GuestShareId, ProviderSessionId, ProviderThreadId, ProviderTurnId, RunAttemptId, } from "@t3tools/contracts"; import { + IDLE_COMPACTION_TEXT, + idleCompactionOf, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, @@ -67,6 +72,7 @@ import { ThreadId, TurnItemId, NodeId, + ProjectId, ProviderInstanceId, } from "@t3tools/contracts"; import { @@ -94,10 +100,12 @@ import { isConversationHistoryItem, threadHistoryTurnStarts, THREAD_HISTORY_MAX_RAW_TURNS, + missingInterruptRequests, + olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, - OLDER_THREAD_USER_TURN_LIMIT, + THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; export class ProjectionStoreApplyEventError extends Schema.TaggedError()( @@ -210,6 +218,7 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" + | "pendingGuestMessageCount" > & Pick & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null; @@ -404,6 +413,25 @@ export interface ShellSnapshotOptions { readonly location?: "active" | "archive"; readonly projectId?: ProjectId; readonly unsettledOnly?: boolean; + /** + * Only the threads sharing this thread's `lineage.rootThreadId`: the root, its forks, and their + * subagent trees. The thread may be deleted: forks keep a deleted source's root. + */ + readonly familyOf?: ThreadId; + readonly worktreePath?: string; + readonly threadIds?: ReadonlyArray; +} + +export interface OlderSettledThreadsQuery { + readonly before: OrchestrationV2OlderSettledCursor; + readonly projectIds?: ReadonlyArray | undefined; + readonly limit: number; +} + +export interface OlderSettledThreadsListing { + readonly threadIds: ReadonlyArray; + /** Only for `limit: 0`. */ + readonly counts: ReadonlyArray; } export interface ProjectionStoreV2Shape { @@ -455,6 +483,10 @@ export interface ProjectionStoreV2Shape { ReadonlyArray, ProjectionStoreV2Error >; + /** Older settled thread ids below a cursor, newest settle first, and per-project counts. */ + readonly listOlderSettledThreads: ( + query: OlderSettledThreadsQuery, + ) => Effect.Effect; /** * Callers that combine the shell with other reads run this inside their own * transaction and the returned decode after it commits, so the shared @@ -472,6 +504,10 @@ export interface ProjectionStoreV2Shape { readonly getThreadShell: ( threadId: ThreadId, ) => Effect.Effect; + /** The thread whose current guest share has this id. Scans thread rows; only redeem uses it. */ + readonly findGuestShareThreadId: ( + shareId: GuestShareId, + ) => Effect.Effect; readonly getThread: ( threadId: ThreadId, ) => Effect.Effect; @@ -728,7 +764,6 @@ function upsertProviderTurn( }); } -/** A stale run snapshot must not erase fields that other events recorded on the run. */ function preserveRunRecordedFields( current: OrchestrationV2Run | undefined, next: OrchestrationV2Run, @@ -828,6 +863,7 @@ export function applyToProjection( case "thread.interaction-mode-updated": case "thread.model-selection-updated": case "thread.provider-switched": + case "thread.guest-share-updated": return { ...base, thread: event.payload, @@ -969,12 +1005,7 @@ export function applyToProjection( } } -/** - * Provider sessions are process-scoped: one session row can be bound to several app - * threads. Updating that row changes what every bound thread reads, even though the - * application event itself belongs to one thread stream. Provider thread owners are global - * too: the latest event wins across app threads. - */ +/** Sessions are shared across app threads, so session events update every binding. Provider-thread ownership is also global: latest wins. */ export interface ProjectionReplayState { readonly projections: Map; readonly providerSessionThreadIds: Map>; @@ -1096,6 +1127,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly latest_run_silent: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1660,6 +1692,16 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + latestRunSilent: + latestRun != null && + projection.messages.some( + (message) => + message.id === latestRun.userMessageId && idleCompactionOf(message) !== undefined, + ) && + !projection.turnItems.some( + (item) => + item.runId === latestRun.id && item.type === "compaction" && item.status === "failed", + ), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1671,6 +1713,7 @@ export function threadShellFromProjection( providerSession?.lastError ?? null, ), pendingRuntimeRequest, + pendingGuestMessageCount: pendingGuestMessageCount(projection.thread.guestShare), // Thread detail owns message bodies. Keeping them out of shell rows makes // initial hydration and streaming updates independent of transcript size. latestVisibleMessage: null, @@ -1766,6 +1809,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly latestRunSilent: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1924,6 +1968,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + latestRunSilent: input.state.latestRunSilent, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -1934,6 +1979,7 @@ function shellFromState(input: { lastErrorCode: input.state.lastErrorCode, usageLimitResetAt: input.state.usageLimitResetAt, pendingRuntimeRequest: input.state.pendingRuntimeRequest, + pendingGuestMessageCount: pendingGuestMessageCount(input.state.thread.guestShare), latestVisibleMessage: null, latestUserMessageAt: input.state.latestUserMessageAt, latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt, @@ -1973,7 +2019,6 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - // For run upserts: a snapshot without `path` keeps the value another event recorded there. const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE WHEN json_type(excluded.payload_json, ${path}) IS NULL @@ -2023,7 +2068,8 @@ export const layer: Layer.Layer = case "thread.runtime-mode-updated": case "thread.interaction-mode-updated": case "thread.model-selection-updated": - case "thread.provider-switched": { + case "thread.provider-switched": + case "thread.guest-share-updated": { const payloadJson = yield* encodeThreadPayload(event.payload); const payload = parseEncodedPayload(payloadJson); yield* sql` @@ -2858,7 +2904,8 @@ export const layer: Layer.Layer = event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" + event.type !== "thread.provider-switched" && + event.type !== "thread.guest-share-updated" ) { const updatedAt = yield* encodeThreadUpdatedAt(event.occurredAt); yield* sql` @@ -3020,6 +3067,17 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors + ), latest_user_turn AS ( + -- Mirrors selectOlderTimelinePage: whole turns while the + -- newest user turn fits the turn row budget, otherwise the + -- window pages from inside that turn with the row limit. + SELECT COUNT(*) AS row_count + FROM ( + SELECT 1 + FROM eligible + WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) + LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} + ) ), selected AS ( -- Choose and sort rows by ID, then fetch payloads in that -- order. A turn window can hold megabytes of tool output, and @@ -3030,7 +3088,9 @@ export const layer: Layer.Layer = ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 - WHEN (SELECT COUNT(*) FROM user_anchors) > 0 THEN -1 + WHEN (SELECT row_count FROM latest_user_turn) + BETWEEN 1 AND ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} + THEN ${THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT} ELSE ${window.rowLimit} END ), retained AS MATERIALIZED ( @@ -3109,8 +3169,7 @@ export const layer: Layer.Layer = ON item.rowid = retained.item_rowid ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; - // Reuse the decoded items for cohort IDs and the resulting projection. - // Parsing these rows separately duplicates every retained tool output. + // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. const turnItems = yield* decodeRows(decodeTurnItemPayload, threadId)(boundedTurnItemRows); const cohortJson = (field: string) => JSON.stringify( @@ -3675,8 +3734,7 @@ export const layer: Layer.Layer = return withLocalVisibleTurnItems(projection); } - // A row-limited segment without turn anchors must finish paging locally - // before inherited user turns can influence the page boundary. + // Page unanchored local segments fully before inherited turns affect the boundary. if ( window?.userTurnLimit !== undefined && localWindow !== undefined && @@ -5014,8 +5072,7 @@ export const layer: Layer.Layer = if (threads.length === 0) { return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } - // Checkpoint diffs need no transcript or fork ancestry. Select just the - // metadata columns so large run/checkpoint JSON payloads stay in SQLite. + // Select checkpoint metadata only; diffs need neither transcripts nor large run/checkpoint JSON. const [runs, checkpointScopes, checkpoints] = yield* Effect.all([ sql` SELECT run_id AS id, ordinal, status @@ -5307,11 +5364,21 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND type = 'dynamic_tool' AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) + `, + // A guest's waiting files are pending uploads that no message row holds yet. + sql<{ id: string }>` + SELECT DISTINCT json_extract(attachment.value, '$.id') AS id + FROM orchestration_v2_projection_threads AS thread, + json_each(thread.payload_json, '$.guestShare.messages') AS message, + json_each(message.value, '$.attachments') AS attachment + WHERE thread.thread_id = ${threadId} + AND json_extract(message.value, '$.status') = 'pending' `, ]).pipe( - Effect.map(([messages, renders]) => [ + Effect.map(([messages, renders, guestFiles]) => [ ...new Set([ ...messages.map((row) => row.id), + ...guestFiles.map((row) => row.id), ...threadHtmlRenderAttachmentIds( threadId, renders.map((row) => { @@ -5518,84 +5585,85 @@ export const layer: Layer.Layer = FROM orchestration_events WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId}`; const snapshotSequence = revisions[0]?.sequence ?? 0; - const page = yield* Effect.try({ - try: () => - selectHistoryPageFromCursor({ - items: index.visible.map((row, position) => ({ - ...row, - position, - canonicalPosition: position, - })), + const indexed = index.visible.map((row, position) => ({ + ...row, + position, + canonicalPosition: position, + })); + const selection = yield* Effect.try({ + try: () => { + const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); + const page = selectHistoryPageFromCursor({ + items: indexed, cursor, snapshotSequence, rowEncodedBytes: () => 0, - throughEntryId, - }), + policy, + }); + return { policy, page }; + }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); + const { policy, page } = selection; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) .some((row) => isThreadHistoryUserTurn(row.item)); - const materialized = - conversationOnly && hasUserTurns - ? page.items.filter((row) => isConversationHistoryItem(row.item)) - : page.items; - const sources = yield* encodeTimelineSources( - materialized - .filter((row) => !row.synthetic) - .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })), - ); - const payloads = yield* sql<{ - thread_id: string; - turn_item_id: string; - payload_json: string; - }>` + const materialize = Effect.fn("ProjectionStore.materializeHistoryRows")(function* ( + rows: ReadonlyArray<(typeof indexed)[number]>, + ) { + if (rows.length === 0) return []; + const sources = yield* encodeTimelineSources( + rows + .filter((row) => !row.synthetic) + .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })), + ); + const payloads = yield* sql<{ + thread_id: string; + turn_item_id: string; + payload_json: string; + }>` SELECT item.thread_id, item.turn_item_id, item.payload_json FROM orchestration_v2_projection_turn_items item JOIN json_each(${sources}) wanted ON item.thread_id = json_extract(wanted.value, '$.threadId') AND item.turn_item_id = json_extract(wanted.value, '$.id')`; - const byIdentity = new Map( - payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]), - ); - const items = yield* Effect.forEach(materialized, (row) => - Effect.gen(function* () { - const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`); - const item = - row.synthetic ?? - (payload === undefined - ? yield* new ProjectionStoreReadError({ threadId }) - : yield* decodeTurnItemPayload(payload)); - return { - position: row.canonicalPosition, - visibility: row.visibility, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - item: projectTurnItemForWire(item), - }; - }), - ); - if (hasUserTurns) - return { - ...page, - snapshotSequence, - items: items.map((row, position) => ({ ...row, position })), - }; - const bounded = selectRecentTimelineWindow({ - items, - snapshotSequence, - policy: { - ...THREAD_HISTORY_PAGE_POLICY, - maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, - maxItems: - THREAD_HISTORY_PAGE_POLICY.maxItems * (throughEntryId === undefined ? 1 : 2), - }, + const byIdentity = new Map( + payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]), + ); + return yield* Effect.forEach(rows, (row) => + Effect.gen(function* () { + const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`); + const item = + row.synthetic ?? + (payload === undefined + ? yield* new ProjectionStoreReadError({ threadId }) + : yield* decodeTurnItemPayload(payload)); + return { + position: row.canonicalPosition, + visibility: row.visibility, + sourceThreadId: row.sourceThreadId, + sourceItemId: row.sourceItemId, + item: projectTurnItemForWire(item), + }; + }), + ); }); + const items = yield* materialize( + conversationOnly && hasUserTurns + ? page.items.filter((row) => isConversationHistoryItem(row.item)) + : page.items, + ); + const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); + const requests = yield* materialize(missingInterruptRequests(indexed, bounded.items)); + const pageItems = [...requests, ...bounded.items].map((row, position) => ({ + ...row, + position, + })); return { snapshotSequence, items: conversationOnly - ? bounded.items.filter((row) => isConversationHistoryItem(row.item)) - : bounded.items, + ? pageItems.filter((row) => isConversationHistoryItem(row.item)) + : pageItems, nextCursor: bounded.hasMoreHistory ? bounded.nextCursor : page.nextCursor, hasMoreHistory: bounded.hasMoreHistory || page.hasMoreHistory, }; @@ -5721,7 +5789,14 @@ export const layer: Layer.Layer = const selectShellThreadRows = ( threadId?: ThreadId, - { location, projectId, unsettledOnly = false }: ShellSnapshotOptions = {}, + { + location, + projectId, + unsettledOnly = false, + familyOf, + worktreePath, + threadIds, + }: ShellSnapshotOptions = {}, ) => sql` SELECT @@ -5742,6 +5817,29 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + -- Mirrors idleCompactionOf, including JavaScript trim whitespace. + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_messages prompt + WHERE prompt.message_id = json_extract(presented.payload_json, '$.userMessageId') + AND prompt.role = 'user' + AND lower(trim(json_extract(prompt.payload_json, '$.text'), + char(9, 10, 11, 12, 13, 32, 160, 5760, 8192, 8193, 8194, 8195, + 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288, 65279) + )) = ${IDLE_COMPACTION_TEXT} + AND coalesce(json_array_length(prompt.payload_json, '$.attachments'), 0) = 0 + AND ( + json_extract(prompt.payload_json, '$.idleCompaction') IS NOT NULL + OR ( + json_extract(prompt.payload_json, '$.createdBy') = 'agent' + AND json_extract(prompt.payload_json, '$.creationSource') = 'server' + ) + ) + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items compaction + WHERE compaction.thread_id = t.thread_id AND compaction.run_id = presented.run_id + AND compaction.type = 'compaction' + AND compaction.status = 'failed' + ) AS latest_run_silent, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -5899,7 +5997,19 @@ export const layer: Layer.Layer = unsettledOnly ? sql` AND json_extract(t.payload_json, '$.settledAt') IS NULL AND json_extract(t.payload_json, '$.settledOverride') IS NOT 'settled'` : sql`` - } + }${ + familyOf === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') + FROM orchestration_v2_projection_threads member + WHERE member.thread_id = ${familyOf} + )` + }${ + worktreePath === undefined + ? sql`` + : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` + }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN ${sql.in(threadIds)}`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -6154,6 +6264,7 @@ export const layer: Layer.Layer = activityRunStatus: null, activityRunStartedAt: null, pendingRuntimeRequest: null, + pendingGuestMessageCount: pendingGuestMessageCount(thread.guestShare), pendingBackgroundTasks: derivePendingBackgroundWork({ latestRun: latestRunId === null || status === "idle" @@ -6278,6 +6389,7 @@ export const layer: Layer.Layer = ? null : DateTime.makeUnsafe(row.latest_run_completed_at); let latestRunStopRequested = row.latest_run_stop_requested === 1; + let latestRunSilent = row.latest_run_silent === 1; if (row.blocking_run_id !== null && row.blocking_failure_payload_json !== null) { const blockingFailure = yield* decodeTurnItemPayload( row.blocking_failure_payload_json, @@ -6291,6 +6403,7 @@ export const layer: Layer.Layer = terminalFailureItem = blockingFailure; latestRunId = RunId.make(row.blocking_run_id); latestRunStopRequested = row.blocking_run_stop_requested === 1; + latestRunSilent = false; latestRunStatus = "failed"; latestRunRequestedAt = row.blocking_run_requested_at === null @@ -6331,6 +6444,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + latestRunSilent, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: @@ -6529,6 +6643,67 @@ export const layer: Layer.Layer = Effect.mapError(shellSnapshotReadError), ); + const findGuestShareThreadId = Effect.fn("ProjectionStore.findGuestShareThreadId")(function* ( + shareId: GuestShareId, + ) { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM orchestration_v2_projection_threads + WHERE json_extract(payload_json, '$.guestShare.id') = ${shareId} + LIMIT 1 + `; + return rows[0] === undefined ? null : ThreadId.make(rows[0].thread_id); + }, Effect.mapError(shellSnapshotReadError)); + + const listOlderSettledThreads: ProjectionStoreV2Shape["listOlderSettledThreads"] = Effect.fn( + "ProjectionStore.listOlderSettledThreads", + )(function* ({ before, projectIds, limit }) { + // ISO strings in one format, so string order is time order. + const older = sql` + SELECT t.thread_id, t.project_id, + json_extract(t.payload_json, '$.settledAt') AS settled_at + FROM orchestration_v2_projection_threads t + WHERE t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.archivedAt') IS NULL + AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.settledOverride') = 'settled' + AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ + projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` + } + `; + const belowCursor = + before.threadId === undefined + ? sql`older.settled_at < ${before.settledAt}` + : sql`(older.settled_at < ${before.settledAt} + OR (older.settled_at = ${before.settledAt} AND older.thread_id > ${before.threadId}))`; + const pageRows = + limit === 0 + ? [] + : yield* sql<{ readonly thread_id: string }>` + WITH older AS (${older}) + SELECT older.thread_id FROM older + WHERE ${belowCursor} + ORDER BY older.settled_at DESC, older.thread_id ASC + LIMIT ${limit} + `; + const countRows = + limit !== 0 + ? [] + : yield* sql<{ readonly project_id: string; readonly count: number }>` + WITH older AS (${older}) + SELECT older.project_id, COUNT(*) AS count FROM older + WHERE ${belowCursor} + GROUP BY older.project_id + `; + return { + threadIds: pageRows.map((row) => ThreadId.make(row.thread_id)), + counts: countRows.map((row) => ({ + projectId: ProjectId.make(row.project_id), + count: row.count, + })), + }; + }, Effect.mapError(shellSnapshotReadError)); + const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) => sql .withTransaction( @@ -6638,7 +6813,9 @@ export const layer: Layer.Layer = Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), NodeSqliteClient.readOnly, ), + listOlderSettledThreads: readOnReader(listOlderSettledThreads), getThreadShell: readOnReader(getThreadShell), + findGuestShareThreadId: readOnReader(findGuestShareThreadId), getThread: readOnReader(getThread), getSettlementCandidates: readOnReader(getSettlementCandidates), getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), @@ -6728,6 +6905,27 @@ export const layerMemory: Layer.Layer = Layer.effect( ) { return false; } + if (options?.familyOf !== undefined) { + const member = existing.get(options.familyOf)?.thread; + if ( + member === undefined || + projection.thread.lineage.rootThreadId !== member.lineage.rootThreadId + ) { + return false; + } + } + if ( + options?.threadIds !== undefined && + !options.threadIds.includes(projection.thread.id) + ) { + return false; + } + if ( + options?.worktreePath !== undefined && + projection.thread.worktreePath !== options.worktreePath + ) { + return false; + } if (options?.location === "active") return projection.thread.archivedAt === null; if (options?.location === "archive") return projection.thread.archivedAt !== null; return true; @@ -6759,6 +6957,52 @@ export const layerMemory: Layer.Layer = Layer.effect( .map(({ thread }) => thread.id); }, ), + listOlderSettledThreads: Effect.fn("ProjectionStore.listOlderSettledThreads")(function* ({ + before, + projectIds, + limit, + }) { + const older = [...(yield* Ref.get(replayState)).projections.values()].flatMap( + ({ thread }) => { + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.lineage.relationshipToParent === "subagent" || + thread.settledOverride !== "settled" || + thread.settledAt === null || + (projectIds !== undefined && !projectIds.includes(thread.projectId)) + ) { + return []; + } + const settledAt = DateTime.formatIso(thread.settledAt); + const below = + settledAt < before.settledAt || + (before.threadId !== undefined && + settledAt === before.settledAt && + thread.id > before.threadId); + return below ? [{ thread, settledAt }] : []; + }, + ); + const counts = new Map(); + for (const { thread } of limit === 0 ? older : []) { + counts.set(thread.projectId, (counts.get(thread.projectId) ?? 0) + 1); + } + return { + threadIds: older + .toSorted((left, right) => + left.settledAt !== right.settledAt + ? left.settledAt > right.settledAt + ? -1 + : 1 + : left.thread.id < right.thread.id + ? -1 + : 1, + ) + .slice(0, limit) + .map(({ thread }) => thread.id), + counts: Array.from(counts, ([projectId, count]) => ({ projectId, count })), + }; + }), readShellSnapshot: (options) => service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), getProjectMoveBlocker: () => Effect.succeed(null), @@ -6772,6 +7016,15 @@ export const layerMemory: Layer.Layer = Layer.effect( .pipe(Effect.map(threadShellFromProjection)); return shell.deletedAt === null ? shell : null; }), + findGuestShareThreadId: Effect.fn("ProjectionStore.findGuestShareThreadId")( + function* (shareId) { + const existing = (yield* Ref.get(replayState)).projections; + for (const { thread } of existing.values()) { + if (thread.guestShare?.id === shareId) return thread.id; + } + return null; + }, + ), getThread: Effect.fn("ProjectionStore.getThread")(function* (threadId) { const projection = (yield* Ref.get(replayState)).projections.get(threadId); if (projection === undefined) { @@ -6998,6 +7251,11 @@ export const layerMemory: Layer.Layer = Layer.effect( ...projection.messages.flatMap((message) => message.attachments.map((attachment) => attachment.id), ), + ...(projection.thread.guestShare?.messages ?? []).flatMap((message) => + message.status === "pending" + ? (message.attachments ?? []).map((attachment) => attachment.id) + : [], + ), ...threadHtmlRenderAttachmentIds( threadId, projection.turnItems.flatMap((item) => @@ -7429,21 +7687,26 @@ export const layerMemory: Layer.Layer = Layer.effect( return yield* new ProjectionStoreThreadNotFoundError({ threadId }); return yield* Effect.try({ try: () => { + const visible = projection.visibleTurnItems.map((row) => ({ + ...row, + item: projectTurnItemForWire(row.item), + })); const page = selectHistoryPageFromCursor({ - items: projection.visibleTurnItems.map((row) => ({ - ...row, - item: projectTurnItemForWire(row.item), - })), + items: visible, cursor, throughEntryId, snapshotSequence, }); + const items = [ + ...missingInterruptRequests(visible, page.items), + ...page.items, + ].map((row, position) => ({ ...row, position })); return { ...page, snapshotSequence, items: conversationOnly - ? page.items.filter((row) => isConversationHistoryItem(row.item)) - : page.items, + ? items.filter((row) => isConversationHistoryItem(row.item)) + : items, }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), @@ -7494,7 +7757,16 @@ export const layerMemory: Layer.Layer = Layer.effect( ? rawStart : anchors.at(-anchorLimit)! : Math.max(0, anchorIndex - options.rowLimit); - const visibleTurnItems = candidates.slice(start); + const latestTurnRows = anchors.length > 0 ? anchorIndex - anchors.at(-1)! : 0; + const rowLimit = + options.rowLimit > 0 && + latestTurnRows > 0 && + latestTurnRows <= THREAD_HISTORY_PAGE_POLICY.maxTurnItems + ? THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT + : options.rowLimit; + const visibleTurnItems = candidates.slice( + Math.max(start, anchorIndex - Math.max(0, rowLimit)), + ); return { ...snapshot, projection: { ...snapshot.projection, visibleTurnItems }, diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts index 6e2abc0c1d6b8..66d39e1c150d1 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -178,9 +178,6 @@ describe("ProviderContinuationService", () => { readonly text: string; readonly notification: OrchestrationV2Notification; }; - // An app-owned child buffers nothing in the adapter, so this text is - // the whole wake. Marking it "provider" would make ClaudeAdapterV2 - // drop it and settle the turn having prompted nothing. assert.notEqual(command.creationSource, "provider"); assert.equal(command.creationSource, "server"); assert.equal(command.text, "Delegated task completed."); @@ -349,11 +346,7 @@ describe("ProviderContinuationService", () => { ), ); - // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect - // it clears its sticky "continuation requested" flag before - // re-raising. If this service pre-recovered the failure before - // handing the effect here, this flag would stay stuck set and - // hasPendingBackgroundWork would never clear. + // ACP clears its sticky continuation flag on raw dispatch failure; pre-recovery would prevent that cleanup. const dispatchIfCurrent = (effect: Effect.Effect) => Effect.gen(function* () { const exit = yield* Effect.exit(effect); @@ -402,11 +395,7 @@ describe("ProviderContinuationService", () => { ), ); - // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch - // effect without any failure handling of its own (unlike ACP or Pi), - // so only the explicit clearIfCurrent callback can clear the sticky - // "wake held" flag. Without it, a disabled instance leaves the flag - // set forever and no later background turn is ever offered. + // Muse forwards dispatch failures unchanged; clearIfCurrent must release its sticky wake flag. const dispatchIfCurrent = (effect: Effect.Effect) => Effect.asSome(effect); diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts index 8aad65111d09d..5435130e689c2 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -201,18 +201,10 @@ export const layer = Layer.effectDiscard( attachments: [], dispatchMode: { type: "queue_after_active" }, createdBy: "agent", - // "provider" marks an adapter-buffered wake, which ClaudeAdapterV2 - // detects to attach the buffered CLI output and drop this text. A - // message_text wake has no buffered output, so it must not carry that - // marker or the turn settles immediately having prompted nothing. + // Only buffered wakes use creationSource "provider"; Claude would drain an empty buffer instead of prompting message_text. creationSource: request.delivery === "message_text" ? "server" : "provider", }); - // This catch sits outside dispatchIfCurrent so an adapter that clears - // its own sticky offer flag on dispatch failure (inside - // dispatchIfCurrent) observes the raw failure first. Not every adapter - // self-clears that way, so the explicit callback is called here too; - // clearIfCurrent implementations are idempotent, so a source that - // already cleared is unaffected. + // Catch after dispatchIfCurrent so adapters see raw failures first; clearIfCurrent is idempotent and covers others. const logInstanceDisabled = ( error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError, ) => diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index 80f1cf1046b06..f6597c5623cf0 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -17,20 +17,23 @@ import { type OrchestrationV2TurnItem, ProviderDriverKind, ProviderInstanceId, - type ProviderThreadId, + ProviderThreadId, ProviderTurnId, RunAttemptId, RunId, RuntimeRequestId, TurnItemId, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; @@ -65,25 +68,63 @@ const layerGit = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); -const layerTest = Layer.mergeAll( - layerTestDatabase, - layerTestStores, - layerTestEventSink, - IdAllocator.layer, - ThreadCommandExecutor.layer, - layerGit, - ProviderEventIngestor.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerTestStores, - layerTestEventSink, - IdAllocator.layer, - ThreadCommandExecutor.layer, - layerGit, +const makeLayerTest = (ingestorEventSink: Layer.Layer) => + Layer.mergeAll( + layerTestDatabase, + layerTestStores, + layerTestEventSink, + IdAllocator.layer, + ThreadCommandExecutor.layer, + layerGit, + ProviderEventIngestor.layer.pipe( + Layer.provide( + Layer.mergeAll( + layerTestStores, + ingestorEventSink, + IdAllocator.layer, + ThreadCommandExecutor.layer, + layerGit, + ), ), ), - ), -); + ); +const layerTest = makeLayerTest(layerTestEventSink); + +// EventSink reports a lock that outlasted busy_timeout at BEGIN IMMEDIATE this way. +const lockTimeout = new EventSink.EventSinkWriteError({ + eventCount: 1, + cause: new SqlError.SqlError({ reason: new SqlError.LockTimeoutError({ cause: null }) }), +}); +const constraintFailure = new EventSink.EventSinkWriteError({ + eventCount: 1, + cause: new SqlError.SqlError({ reason: new SqlError.ConstraintError({ cause: null }) }), +}); + +/** The ingestor's next sink writes of `events` fail with `errors`, in order; the rest go through. */ +const layerFailingWrites = ( + errors: ReadonlyArray, + matches: (events: ReadonlyArray) => boolean = () => true, +) => + makeLayerTest( + Layer.effect( + EventSink.EventSinkV2, + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const pending = [...errors]; + const failNext = ( + events: ReadonlyArray, + write: Effect.Effect, + ) => Effect.suspend(() => (matches(events) ? (pending.shift() ?? write) : write)); + return EventSink.EventSinkV2.of({ + ...eventSink, + write: (input) => failNext(input.events, eventSink.write(input)), + writeIfRunCurrent: (input) => failNext(input.events, eventSink.writeIfRunCurrent(input)), + writeIfProviderThreadOwner: (input) => + failNext(input.events, eventSink.writeIfProviderThreadOwner(input)), + }); + }), + ).pipe(Layer.provide(layerTestEventSink)), + ); const makeSharedWorktree = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -233,6 +274,12 @@ it.effect("records accepted billed turn usage once without billing the context w modelSelection, runtimeMode: "full-access" as const, interactionMode: "default" as const, + origin: { + createdBy: "agent" as const, + creationSource: "provider" as const, + scheduled: false, + subagent: true, + }, }, event: { type: "provider_turn.updated" as const, driver: CODEX_DRIVER, providerTurn }, }; @@ -271,6 +318,10 @@ it.effect("records accepted billed turn usage once without billing the context w mixedModels: false, runtimeMode: "full-access", interactionMode: "default", + createdBy: "agent", + creationSource: "provider", + scheduled: false, + subagent: true, durationMs: 120, }); }).pipe(Effect.provide(layerTest.pipe(Layer.provide(layerAnalytics)))); @@ -1758,7 +1809,6 @@ layer("ProviderEventIngestorV2", (it) => { }, ], }); - // The rename's final scan finishes while the child is still uncommitted. const snapshot = yield* projections.getShellSnapshot(); assert.isFalse( [...snapshot.threads, ...snapshot.archivedThreads].some( @@ -1995,7 +2045,7 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect("moves a native subagent's thread to the model its provider reports later", () => + it.effect("moves a native subagent's thread to its later model through a database lock", () => Effect.gen(function* () { const now = yield* DateTime.now; const eventSink = yield* EventSink.EventSinkV2; @@ -2114,7 +2164,16 @@ layer("ProviderEventIngestorV2", (it) => { (yield* projectionStore.getThread(childThreadId)).modelSelection, reportedSelection, ); - }), + }).pipe( + // Fresh: the shared test layer has already built the ingestor this replaces. + Effect.provide( + Layer.fresh( + layerFailingWrites([lockTimeout, lockTimeout], (events) => + events.some((event) => event.type === "thread.model-selection-updated"), + ), + ), + ), + ), ); it.effect( @@ -2295,3 +2354,91 @@ layer("ProviderEventIngestorV2", (it) => { }), ); }); + +const ingestCompletedProviderTurn = ( + guard: Pick< + Parameters[0], + "writeIfRunCurrent" | "writeIfProviderThreadOwner" + >, +) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const threadEvent = yield* threadCreatedEvent(now); + yield* eventSink.write({ events: [threadEvent] }); + const providerTurnId = ProviderTurnId.make("provider-turn:locked"); + const exit = yield* ingestor + .ingestNormalized({ + providerSessionId: yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + }), + providerInstanceId: modelSelection.instanceId, + threadId: threadEvent.threadId, + event: { + type: "provider_turn.updated", + driver: CODEX_DRIVER, + providerTurn: { + id: providerTurnId, + providerThreadId: ProviderThreadId.make("provider-thread:locked"), + nodeId: NodeId.make("node:locked-turn"), + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + ...guard, + }) + .pipe(Effect.exit); + const projection = yield* projectionStore.getThreadProjection(threadEvent.threadId); + return { exit, providerTurnId, persisted: projection.providerTurns.map((turn) => turn.id) }; + }); + +// Neither guard matches a stored run, so a guarded write correctly commits nothing. +const runId = RunId.make("run:locked"); +const activeAttemptId = RunAttemptId.make("attempt:locked"); +it.effect.each([ + { route: "write", guard: {} }, + { + route: "writeIfRunCurrent", + guard: { writeIfRunCurrent: { runId, activeAttemptId, expectedStatus: "running" } }, + }, + { + route: "writeIfProviderThreadOwner", + guard: { + writeIfProviderThreadOwner: { + providerThreadId: ProviderThreadId.make("provider-thread:locked"), + runId, + activeAttemptId, + expectedLastRunOrdinal: 1, + }, + }, + }, +] as const)("a provider event $route outlasts a brief database lock", ({ route, guard }) => + Effect.gen(function* () { + const { exit, providerTurnId, persisted } = yield* ingestCompletedProviderTurn(guard); + assert.isTrue(Exit.isSuccess(exit)); + assert.deepEqual(persisted, route === "write" ? [providerTurnId] : []); + }).pipe(Effect.provide(layerFailingWrites([lockTimeout, lockTimeout]))), +); + +// The write after the queued errors would succeed, so failing proves the retries stopped. +it.effect.each([ + { failure: "stays locked", errors: [lockTimeout, lockTimeout, lockTimeout] }, + { failure: "fails for another reason", errors: [constraintFailure] }, +])("fails a provider event write that $failure", ({ errors }) => + Effect.gen(function* () { + const { exit, persisted } = yield* ingestCompletedProviderTurn({}); + assert.instanceOf( + Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined, + ProviderEventIngestor.ProviderEventPublishError, + ); + assert.isEmpty(persisted); + }).pipe(Effect.provide(layerFailingWrites(errors))), +); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 46fa54d090ae2..e8890208ccd7e 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -9,6 +9,8 @@ import { type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, type ModelSelection, + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, type RuntimeMode, type ProviderInteractionMode, ProviderInstanceId, @@ -25,6 +27,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlError from "effect/sql/SqlError"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; @@ -68,6 +71,14 @@ export class ProviderEventPublishError extends Schema.TaggedError + SqlError.isSqlError(error.cause) && error.cause.reason._tag === "LockTimeoutError"; +const retryLockTimeout = (write: Effect.Effect) => + Effect.retry(write, { while: isLockTimeout, times: 2 }); + export const ProviderEventIngestorV2Error = Schema.Union([ ProviderEventNormalizeError, ProviderEventPublishError, @@ -78,6 +89,19 @@ export interface ProviderTurnAnalyticsContext { readonly modelSelection: ModelSelection; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; + readonly origin?: ProviderTurnOrigin; +} + +/** + * Where the run's starting message came from. Most turns start without a + * person (provider continuations, agent sends, delegated subagents), so + * reports need this to tell user turns apart. + */ +export interface ProviderTurnOrigin { + readonly createdBy: OrchestrationV2Actor; + readonly creationSource: OrchestrationV2CreationSource; + readonly scheduled: boolean; + readonly subagent: boolean; } export class ProviderTurnAnalytics extends Context.Reference<{ @@ -127,6 +151,7 @@ function providerTurnAnalyticsProperties(input: { ...(effort ? { effort } : {}), ...(input.context?.runtimeMode ? { runtimeMode: input.context.runtimeMode } : {}), ...(input.context?.interactionMode ? { interactionMode: input.context.interactionMode } : {}), + ...input.context?.origin, ...(input.providerTurn.startedAt && input.providerTurn.completedAt ? { durationMs: Math.max( @@ -391,12 +416,7 @@ export const layer: Layer.Layer< }, ); - /** - * A native subagent's thread starts on the parent's model when the provider - * names the real one later (a Claude agent file's model arrives with the - * subagent's first reply). Thread commands rewrite the whole thread row - * under the thread's lock, so this read and write take it too. - */ + /** Providers may reveal native subagent models late; lock full-row updates against concurrent thread commands. */ const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { const { childThreadId, model } = subagent; @@ -435,7 +455,7 @@ export const layer: Layer.Layer< }, occurredAt: now, }); - return yield* eventSink.write({ events: [event] }); + return yield* eventSink.write({ events: [event] }).pipe(retryLockTimeout); }), ); }, @@ -815,7 +835,7 @@ export const layer: Layer.Layer< ...input.writeIfProviderThreadOwner, events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); return ownerResult.storedEvents; } if (input.writeIfRunCurrent === undefined) { @@ -825,7 +845,7 @@ export const layer: Layer.Layer< ...(input.commandId === undefined ? {} : { commandId: input.commandId }), events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); } const result = yield* eventSink .writeIfRunCurrent({ @@ -835,7 +855,7 @@ export const layer: Layer.Layer< ...input.writeIfRunCurrent, events, }) - .pipe(Effect.mapError(mapWriteError)); + .pipe(retryLockTimeout, Effect.mapError(mapWriteError)); return result.storedEvents; }, (effect, input) => diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index decef351df6d8..a994a52a78e86 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -176,11 +176,7 @@ export const make = Effect.gen(function* () { const ids = yield* IdAllocator.IdAllocatorV2; const outbox = yield* EffectOutbox.EffectOutboxV2; type RecoverySession = ProjectionStore.ProjectionRuntimeRecoveryState["providerSessions"][number]; - /** - * A session shared by several threads (one Codex app server) is stopped by the - * first thread recovery reaches. It was live when the server went down, so a - * later thread still decides its wake as if it were. - */ + /** Shared sessions stop on their first recovered thread; later threads still decide wakes from pre-recovery liveness. */ const sessionsLiveAtStart = ( projection: ProjectionStore.ProjectionRuntimeRecoveryState, stoppedThisPass: ReadonlyMap, @@ -445,8 +441,7 @@ export const make = Effect.gen(function* () { candidate.status === "running" || candidate.status === "waiting"), )) { - // A native subagent does not come back when its cut turn resumes, so the - // turn's continuation is told to restart it. + // Cut native subagents do not resume; record them for the continuation to restart. if (run.status === "waiting") recordCancelledBackgroundItem(item); else if (item.type === "subagent" && item.origin === "provider_native") recordCancelledBackgroundItem(item); @@ -462,9 +457,7 @@ export const make = Effect.gen(function* () { }); } } - // A blocking delegate_task wait dies with the turn that made it, and its - // continuation never got the tool result. Let the child's result wake - // the parent like an async task, so it reaches the continued turn. + // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); for (const task of projection.subagents ?? []) { if ( @@ -531,8 +524,7 @@ export const make = Effect.gen(function* () { } } } - // No provider process outlives the server, so a native subagent still open - // here never reports its end, whatever state its run and item are in. + // No provider process survives the server; open native subagents can never report completion. for (const subagent of projection.subagents ?? []) { events.push( ...(yield* endOrphanedNativeSubagent({ @@ -545,8 +537,7 @@ export const make = Effect.gen(function* () { })), ); } - // A provider-native subagent thread has no runs; left running, the child - // would show as working forever. + // Native subagent threads have no runs to settle their runless root turns. events.push( ...(yield* endRunlessRootTurns({ threadId: projection.thread.id, @@ -608,8 +599,7 @@ export const make = Effect.gen(function* () { for (const [providerThreadId, work] of cancelledBackgroundWork) { const noteRun = latestStartedRun(projection, providerThreadId); if (noteRun === undefined) continue; - // Its own event: a run snapshot read before this commit could regress - // a lifecycle change (e.g. a checkpoint completing the run) made since. + // Use a separate event; stale run snapshots can overwrite intervening lifecycle changes. events.push({ id: yield* allocateEventId(), type: "run.background-work-cancelled", diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index b60758e80c728..010f11742933e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -345,9 +345,7 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( closeCount: current.closeCount + 1, })), ); - // Registered after countClose so it runs first on scope close, wedging the - // close before the closeCount finalizer, like a provider process that never - // yields its message stream. + // Register after countClose to wedge teardown before its finalizer, matching a provider stream that never yields. const hangClose = Effect.addFinalizer(() => (options.scopeCloseReached === undefined ? Effect.void @@ -1242,8 +1240,7 @@ it.effect( () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); - // A's adapter start installs its turn, then is stopped mid-request; like - // OpenCode2, the adapter still ends that turn with turn.terminal later. + // Like OpenCode2, a stopped start can still emit turn.terminal later. const holdStart = yield* Ref.make(false); const startHeld = yield* Deferred.make(); yield* Effect.gen(function* () { @@ -1563,6 +1560,59 @@ it.effect( }), ); +it.effect.each([ + { relationshipToParent: null, subagent: false }, + { relationshipToParent: "subagent", subagent: true }, +] as const)( + "ProviderSessionManagerV2 marks MCP credentials of delegated children ($relationshipToParent)", + ({ relationshipToParent, subagent }) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-mcp-lineage"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + const created = yield* makeThreadCreatedEvent({ idAllocator, threadId, now }); + const parentThreadId = ThreadId.make("thread-provider-session-manager-mcp-parent"); + yield* eventSink.write({ + events: [ + { + ...created, + payload: { + ...created.payload, + lineage: + relationshipToParent === null + ? created.payload.lineage + : { parentThreadId, relationshipToParent, rootThreadId: parentThreadId }, + }, + }, + ], + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + + const token = (yield* Ref.get(mcpConfigs))[0]?.authorizationHeader.replace( + /^Bearer\s+/, + "", + ); + assert.isDefined(token); + assert.equal((yield* registry.resolve(token!))?.thread.subagent, subagent); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs }))); + }), +); + it.effect( "ProviderSessionManagerV2 withholds the preview capability when agent browser access is off", () => @@ -2021,9 +2071,6 @@ it.effect( const originalToken = original?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(originalToken); - // Workspace-change handoff on a shared multi-thread session (codex): - // the thread detaches while the provider process keeps running, and the - // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( (yield* registry.resolve(originalToken!))?.thread.threadId, @@ -2031,9 +2078,6 @@ it.effect( "detach must not revoke the credential the live provider process still holds", ); - // The continuation run re-attaches the same thread to the same session; - // the credential must be reused, not rotated, so the provider process's - // long-lived MCP client stays authorized. yield* manager.open({ threadId, providerSessionId, @@ -2098,11 +2142,7 @@ it.effect( assert.isDefined(originalToken); yield* manager.detach({ providerSessionId: s1, threadId }); - // While S2's provider process is spawning (after prepare reused the - // credential, before the entry is visible), the predecessor session - // releases. Eager adapters (ACP, OpenCode) bake the credential into - // the process during openSession, so the release must not revoke it; - // rotating afterwards cannot repair those adapters. + // ACP and OpenCode consume credentials during openSession; predecessor release must not revoke them before entry publication. yield* Ref.set(duringOpen, manager.close(s1).pipe(Effect.orDie)); yield* manager.open({ threadId, providerSessionId: s2, modelSelection, runtimePolicy }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index e82995d1db77f..00b42d510bd91 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -190,12 +190,7 @@ export class ProviderSessionManagerV2 extends Context.Service< interface LiveSessionEntry { readonly attachedThreadIds: ReadonlySet; readonly loadedProviderThreadKeyByThread: ReadonlyMap; - /** - * Revocation on detach/release is scoped to these ids so tearing down a - * superseded session cannot revoke a replacement session's credential for the - * same thread (the workspace-handoff sequence opens the replacement before - * the outbox executes the old session's detach). - */ + /** Scope revocation to recorded credential ids so superseded sessions cannot revoke replacements opened before detach. */ readonly mcpCredentialIdByThread: ReadonlyMap; readonly supportsMultipleProviderThreads: boolean; readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; @@ -315,12 +310,7 @@ export const layerWithOptions = ( const fileSystem = yield* FileSystem.FileSystem; const mcpSessionRegistry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - /** - * When present, an unreadable settings file withholds browser access - * rather than granting it — an explicit "off" silently becoming "on" - * would violate the user's stated choice, whereas the reverse costs an - * agent one toolset and is visible immediately (#7083). - */ + /** Unreadable settings must preserve explicit browser-access opt-outs. */ const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const projectService = yield* Effect.serviceOption(ProjectService.ProjectService); const eventSink = yield* EventSink.EventSinkV2; @@ -368,10 +358,7 @@ export const layerWithOptions = ( }, ); const layerScope = yield* Effect.scope; - // Ctrl+C, or a stop that signals the whole process group, reaches the - // provider CLIs with the server. They report their own background work - // stopped before shutdown captures restart continuations, so provider - // events after the signal are dropped; restart recovery owns that state. + // Group signals stop providers before restart capture; drop post-signal events so recovery owns that state. const shutdownSignal = { received: false }; const onShutdownSignal = () => { shutdownSignal.received = true; @@ -388,8 +375,7 @@ export const layerWithOptions = ( }), ); const sessions = yield* Ref.make(new Map()); - // One retry per released entry, so a later release with the same id - // cannot drop cleanup for threads only the earlier session served. + // Retry per released entry; reusing ids must not lose cleanup for earlier threads. const releaseRecordRetries = yield* FiberSet.make(); const nextSubscriberId = yield* Ref.make(0); const sessionOpen = yield* KeyedLock.make(); @@ -404,11 +390,7 @@ export const layerWithOptions = ( readonly mcpCredentialId: string | undefined; readonly issued: boolean; } - /** - * Adapters like ACP and OpenCode consume the credential eagerly during - * openSession, so a racing release must not revoke it in that window - * (rotating afterwards cannot repair an already-configured process). - */ + /** ACP and OpenCode consume credentials during openSession; reserve them against revocation until publication. */ const mcpCredentialReservations = new Map(); const mcpReservationKey = (threadId: ThreadId, mcpCredentialId: string) => `${threadId}\0${mcpCredentialId}`; @@ -428,10 +410,7 @@ export const layerWithOptions = ( const isMcpCredentialReserved = (threadId: ThreadId, mcpCredentialId: string) => (mcpCredentialReservations.get(mcpReservationKey(threadId, mcpCredentialId)) ?? 0) > 0; const mcpPrepareLock = yield* KeyedLock.make(); - /** - * Serialized per thread so two concurrent prepares cannot interleave - * their rotate steps and revoke each other's freshly minted credential. - */ + /** Serialize prepares per thread so concurrent rotations cannot revoke each other's credentials. */ const prepareMcpSession = ( threadId: ThreadId, providerInstanceId: ProviderInstanceId, @@ -443,12 +422,7 @@ export const layerWithOptions = ( : mcpPrepareLock.withLock( threadId, Effect.gen(function* () { - // Reuse a still-valid credential for this thread instead of - // rotating: long-lived provider processes (codex app-server) - // build their MCP client once per conversation and keep using - // the credential it started with, so a thread that detaches and - // re-attaches across a workspace handoff must come back to the - // same token or the process's tool calls fail auth. + // Live providers build MCP clients once; reuse credentials across workspace handoffs. const { browser: browserToolsAvailable, device: deviceToolsAvailable } = yield* agentAccessSettings(threadId); const capabilities = new Set< @@ -456,14 +430,16 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + const subagent = yield* projectionStore.getThread(threadId).pipe( + Effect.map((thread) => thread.lineage.relationshipToParent === "subagent"), + Effect.orElseSucceed(() => false), + ); const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { - // Reserve before the async resolve so a release cannot - // revoke the credential between validation and reservation. + // Reserve before async validation so release cannot revoke between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - // The caller only learns of the reservation once this returns, - // so a stop while resolving must drop it here. + // Interrupted resolution must release the reservation before the caller learns of it. const resolved = yield* mcpSessionRegistry .resolve(rawToken) .pipe( @@ -478,7 +454,9 @@ export const layerWithOptions = ( resolved.thread.threadId === threadId && resolved.thread.providerInstanceId === providerInstanceId && resolved.capabilities.has("preview") === browserToolsAvailable && - resolved.capabilities.has("device") === deviceToolsAvailable + resolved.capabilities.has("device") === deviceToolsAvailable && + // A credential issued while the lineage read failed is replaced once it works. + (resolved.thread.subagent === true) === subagent ) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } @@ -490,6 +468,7 @@ export const layerWithOptions = ( providerInstanceId, browserToolsAvailable, capabilities, + subagent, }); yield* mcpSessions.set(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); @@ -733,11 +712,8 @@ export const layerWithOptions = ( } }); - // Read as soon as the session leaves the live map: after that, a - // replacement session with the same id can open and report tasks of its - // own on the same provider threads. OpenCode's background subagents run - // on a server shared by its sessions and can outlive this one, so their - // roster stays. + // Read tasks as soon as the session leaves the live map, before a replacement reports its own. + // Keep OpenCode's roster: its background subagents share a server and can outlive the session. const readReleasedBackgroundTasks = Effect.fn( "ProviderSessionManager.readReleasedBackgroundTasks", )( @@ -789,8 +765,7 @@ export const layerWithOptions = ( ReturnType >[number]; - // Their process is gone, so nothing will report them ending, and a - // leftover roster keeps the thread Waiting (#15239). + // Dead processes cannot report task endings; stale rosters keep threads Waiting. const writeReleasedBackgroundWorkEvents = ( released: ReadonlyArray, ) => @@ -863,9 +838,7 @@ export const layerWithOptions = ( { concurrency: 1 }, ).pipe(Effect.flatMap(Exit.asVoidAll)); - // The session already left the live map, so a later release finds nothing - // to do. Without a retry the UI would keep a ready session and answerable - // approvals until a server restart. + // Retry after removal from the live map; later releases cannot clear stale ready sessions and approvals. const retryReleaseRecords = ( input: Omit[0], "replaced">, ) => { @@ -885,8 +858,7 @@ export const layerWithOptions = ( providerSessionId, cause: exit.cause, }); - // A failed SQL commit is a defect, so every failure but interruption - // is retried. + // SQL commit failures are defects; retry every cause except interruption. return yield* Effect.fail(exit.cause); }); return attempt.pipe( @@ -1063,20 +1035,13 @@ export const layerWithOptions = ( Option.match(entry, { onNone: () => Effect.void, onSome: (entry) => - // Revoke every credential this session recorded, including for - // threads that detached without re-attaching: the provider - // process is gone, so nothing holds them anymore. Skip threads - // a live replacement session took over, since credential reuse - // means the replacement may hold this very credential. + // Revoke recorded credentials after process death, except those held by live replacements. Ref.get(sessions).pipe( Effect.flatMap((current) => Effect.forEach( entry.mcpCredentialIdByThread, ([threadId, mcpCredentialId]) => { - // Id-sensitive: a stale record for the same thread but - // a DIFFERENT credential (left behind by an old session - // the thread rotated away from) must not veto revoking - // this session's own credential, or it leaks forever. + // Only holders of this credential may prevent its revocation; stale credentials must not leak it. const heldElsewhere = isMcpCredentialReserved(threadId, mcpCredentialId) || Array.from(current.values()).some( @@ -1121,8 +1086,7 @@ export const layerWithOptions = ( ) { return; } - // Capture runtime identity before yielding: a replacement session - // can reuse the same providerSessionId while this fiber is parked. + // Capture runtime identity before yielding; replacements can reuse the session id. const probedRuntime = entry.runtime; const hasPendingWork = probedRuntime.hasPendingBackgroundWork === undefined @@ -1155,9 +1119,7 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, pinnedForMs: now - pinnedSinceMs, }); - // Re-check on this fiber after another idle window. Do not call - // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which - // is this fiber, and can self-deadlock on Fiber.interrupt. + // Recheck on this fiber; scheduleIdleReleaseInternal would interrupt it and self-deadlock. yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* releaseIfStillIdle(input); } @@ -1166,10 +1128,7 @@ export const layerWithOptions = ( pinnedForMs: now - pinnedSinceMs, }); } - // hasPendingBackgroundWork yields to the adapter, so the idle - // decision above can go stale; the generation guard revalidates - // busyTurns and idleGeneration inside releaseEntry's atomic - // entry removal. + // Adapter probes yield; guard busyTurns and idleGeneration atomically when removing the entry. yield* releaseEntry({ providerSessionId: input.providerSessionId, reason: "idle_timeout", @@ -1362,13 +1321,10 @@ export const layerWithOptions = ( dropMcpCredentialReservation(input.threadId, preparedForCleanup.mcpCredentialId); } }; - // The whole attach, including undoing a failed one, holds the - // thread's lock: a concurrent attach of the same thread waits, so it - // never sees an attachment that this call is about to roll back. + // Lock attach through rollback so concurrent attaches cannot observe an attachment about to be undone. const attach = Effect.gen(function* () { const attached = yield* attachThread(input).pipe( - // Recorded with no gap for an interrupt: cleanup undoes only an - // attach this call made, never one an earlier open made. + // Record without interruption so cleanup undoes only this call's attachment. Effect.tap((runtime) => Effect.sync(() => (attachedTo = runtime))), Effect.uninterruptible, ); @@ -1409,10 +1365,7 @@ export const layerWithOptions = ( Effect.andThen( Effect.suspend(() => { dropReservation(); - // Revoke only a credential this attach freshly minted: a - // REUSED credential is by definition held by another - // live provider process, and revoking it thread-wide - // would break that process's MCP client mid-conversation. + // Other live processes hold reused MCP credentials; revocation would break their clients. if (preparedForCleanup?.issued !== true) return Effect.void; const mcpCredentialId = preparedForCleanup.mcpCredentialId; const attachedRuntime = attachedTo; @@ -1605,8 +1558,7 @@ export const layerWithOptions = ( ) { return "skipped" as const; } - // Unloading stops the native thread's background terminals, so a - // thread still running background work stays loaded. + // Unloading kills background terminals; keep threads with pending work loaded. const hasPendingWork = entry.runtime.hasPendingBackgroundWorkForThread === undefined ? false @@ -1651,8 +1603,7 @@ export const layerWithOptions = ( return "unloaded" as const; }), ); - // Re-check on this fiber after another idle window, outside the - // lock so the thread's next attach is not held up meanwhile. + // Wait outside the lock so the next attach is not blocked. if (outcome === "deferred") { yield* Effect.sleep(Duration.millis(idleTimeoutMs)); return yield* unloadIdleThread(input); @@ -1843,8 +1794,7 @@ export const layerWithOptions = ( providerInstanceId: runtime.instanceId, }), ).pipe( - // A start that fails or is stopped may never emit turn.terminal, - // so it clears its own turn or the session never goes idle. + // Failed or stopped starts may omit turn.terminal; clear their turns so sessions can idle. Effect.andThen( holdThreadLoaded({ providerSessionId, @@ -1931,12 +1881,10 @@ export const layerWithOptions = ( return entry.runtime.events.pipe( Stream.runForEach((event) => { if (shutdownSignal.received) return Effect.void; - // Live output skips run subscribers and ingestion entirely: it is - // never persisted, and a flood of it must not queue ahead of real events. + // Live output bypasses persistence and subscribers so floods cannot delay domain events. if (event.type === "command_output.delta") { return commandOutput.append(event); } - // A settled item carries its final output, so its live tail can go. const releaseLiveOutput = event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && @@ -2070,8 +2018,7 @@ export const layerWithOptions = ( ); }; - // The service launcher kills the server 5 s after it asks it to stop, and - // a busy server can hold 15 sessions. + // Shutdown must fit the launcher's 5 s deadline with up to 15 sessions. const sessionScopes = yield* Scope.make("parallel"); const shutdown = Effect.gen(function* () { const activeSessions = [...(yield* Ref.get(sessions)).values()]; @@ -2183,10 +2130,7 @@ export const layerWithOptions = ( }) .pipe( Effect.provideService(Scope.Scope, sessionScope), - // The session cleanup runs first, and the close is - // time-boxed: this runs under the session's open lock, so an - // adapter finalizer that never finishes must not hold the - // interrupter, the lock, or later opens. + // Bound scope close after cleanup; a wedged finalizer must not hold the open lock or interrupter. Effect.onError(() => dropReservation.pipe( Effect.andThen( @@ -2257,8 +2201,7 @@ export const layerWithOptions = ( payload: runtime.providerSession, }), ).pipe( - // Released on interrupt too: this entry has no event pump or - // idle timer yet, so nothing else would ever release it. + // Release on interruption too; no event pump or idle timer exists to clean up yet. Effect.onError((cause) => releaseEntry( Cause.hasInterruptsOnly(cause) @@ -2443,10 +2386,7 @@ export const layerWithOptions = ( yield* cancelIdleFiber(detachResult.value.idleUnloadFiber); } const detached = Option.map(detachResult, (result) => result.entry); - // Plain detaches deliberately do not revoke: a detached thread's - // provider process may still be alive (shared multi-thread codex - // session across a workspace handoff) and holds its MCP client's - // credential for the thread it will re-attach with. + // Plain detaches preserve credentials held by live shared providers across workspace handoffs. if (input.revokeMcpCredential === true) { yield* clearMcpSession(input.threadId); } @@ -2467,9 +2407,7 @@ export const layerWithOptions = ( } const unloadThread = detached.value.exposedRuntime.unloadThread; if (detached.value.supportsMultipleProviderThreads && unloadThread !== undefined) { - // Serialized with re-attachment: a thread whose next turn - // attaches first stays loaded, and one that attaches during the - // unload waits for it, so its resume reloads the native thread. + // Serialize unload with attachment so attached threads stay loaded and later attaches resume after unload. yield* threadAttachment.withLock( threadAttachmentKey(input), Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts index 784d03de6dcb1..1ccc7f9f4f76f 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionTransitionPolicy.ts @@ -91,6 +91,6 @@ export function decideProviderSessionTransition(input: { } } - // Interaction mode is turn-scoped and is applied when the next turn starts. + // Apply interaction mode on the next turn. return { type: "reuse" }; } diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index ed343e13e7f51..6e10b2fdf02ae 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -181,8 +181,7 @@ export const layer: Layer.Layer< .filter((session) => { if (!isLiveProviderSession(session)) return false; if (transition.type === "restart_and_resume") { - // Other live records may serve pooled or delegated bindings; - // only the session being replaced is released. + // Release only the replaced session; peers can serve pooled or delegated bindings. return session.id === currentSession?.id; } if (transition.type === "create_with_handoff") { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 82743cffc165b..8d7591dfd3060 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -212,9 +212,11 @@ it.effect( getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), getShellSnapshot: () => Effect.die("unused getShellSnapshot"), listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"), + listOlderSettledThreads: () => Effect.die("unused listOlderSettledThreads"), readShellSnapshot: () => Effect.die("unused readShellSnapshot"), getProjectMoveBlocker: () => Effect.succeed(null), getThreadShell: () => Effect.die("unused getThreadShell"), + findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), getThreadsWithPullRequests: () => Effect.die("unused getThreadsWithPullRequests"), diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 56bbae37dd91e..3a5bc04ecba2e 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -213,16 +213,13 @@ export const layer: Layer.Layer< : { requestedAt: input.subagent.requestedAt }), }); } - // A settled turn reaches its adapter too: only the adapter knows - // whether it still runs work for the thread, and each one either - // stops it or reports there is nothing left to stop. + // Settled turns still reach adapters, which alone know whether background work remains. yield* session.value.interruptTurn({ providerThread: loaded.providerThread, providerTurnId: loaded.providerTurn.id, requestRuntimeRestart: true, }); - // The wait is real time: ingestion runs on other fibers and never - // advances a test clock, so a test clock would hold Stop forever. + // Use real time; ingestion fibers do not advance TestClock and would leave Stop blocked. yield* Effect.gen(function* () { const deadline = (yield* Clock.currentTimeMillis) + 2_000; while ( @@ -258,9 +255,7 @@ export const layer: Layer.Layer< function* (input) { const loaded = yield* load({ ...input, operation: "restart" }); if (Option.isNone(loaded.session)) { - // No live adapter: nothing can emit a terminal provider-turn update - // from interrupt. Do not poll for projection terminalization or the - // restart effect stalls; let detach/start proceed. + // Without a live adapter, no terminal event can arrive; proceed to detach/start without polling. if (loaded.providerTurn.status === "running") { yield* Effect.logWarning( "Provider restart interrupt skipped; no live session for a still-projected running turn", @@ -296,10 +291,7 @@ export const layer: Layer.Layer< ) { return; } - // Provider terminal events are projected on a detached ingestion - // fiber. Yield through the Node event loop instead of sleeping on - // Effect's clock so deterministic runtimes cannot deadlock a - // command that is waiting for that projection. + // Yield through Node's event loop; detached ingestion cannot advance a deterministic Effect clock. yield* yieldToRuntime; } return yield* new ProviderTurnControlError({ diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 4994953c3809a..ee1df12130bb4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -66,7 +66,6 @@ const layerWorktreeLocation = (worktreesDir = managedWorktreesDir) => ServerSettings.layerTest(), ); -/** Starts a run on a thread whose worktree folder is missing, with routing reads failing after. */ function startWithMissingWorktree( worktreePath: string, realPaths: Record = { @@ -130,7 +129,6 @@ function startWithMissingWorktree( IdAllocator.layer, Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false), - // Paths that exist on the fake disk, mapped to where their symlinks lead. realPath: (target: string) => normalize(target) in realPaths ? Effect.succeed(realPaths[normalize(target)]) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 31eb4289a2d9c..9569bd4aaa0f0 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -135,8 +135,7 @@ export const layer: Layer.Layer< const runtimePolicy = yield* RuntimePolicy.RuntimePolicyV2; const isManagedWorktreePath = yield* makeIsManagedWorktreePath; - // These callbacks outlive startup while a run drains background work. Build - // them outside start's scope so they cannot retain its full thread history. + // Build draining callbacks outside startup scope so they cannot retain full thread history. const makeRunControls = (input: { readonly threadId: ThreadId; readonly runId: RunId; @@ -405,8 +404,7 @@ export const layer: Layer.Layer< if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) { const isEmptyCompaction = message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; - // Preparing a run may already point the thread at a newly selected - // provider. Account commands still belong to its last native session. + // Preparation may select a new provider; account commands still target the last native session. const nativeThreads = new Map( projection.providerThreads .filter( @@ -745,10 +743,7 @@ export const layer: Layer.Layer< ); } if (providerThread.nativeThreadRef === null) { - // Hand the run's provider thread to the adapter so it adopts this - // row's identity when attaching native state. An adapter that mints - // its own row instead leaves two live rows per app thread, and - // `activeProviderThreadId` then flaps between them on every update. + // Pass the stored provider row for adoption; adapter-minted duplicates make activeProviderThreadId flap. return yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, @@ -802,9 +797,7 @@ export const layer: Layer.Layer< modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, - // The native ref is dropped so the adapter binds a fresh native - // session instead of retrying the resume that just failed, while - // still adopting this row's identity. + // Clear the native ref to force a fresh session while preserving row identity. existingProviderThread: { ...providerThread, nativeThreadRef: null }, }), ); @@ -1063,8 +1056,7 @@ export const layer: Layer.Layer< linked: linkedCheckouts, records: contextRecords, }); - // Delivered once: this run's provider turn marks the work as told. A - // restart continuation is prompted by its own text or resumes natively. + // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( run, projection.runs, @@ -1142,8 +1134,7 @@ export const layer: Layer.Layer< ) .map((source) => source.id), ); - // Legacy attempts lack native identity; exclude their explicitly recovered history, whose - // attachments were not replayed into the replacement thread. + // Legacy attempts lack native ids; exclude recovered attachments absent from the replacement thread. const nativeContextEstimate = Effect.gen(function* () { return sameNativeThread ? (yield* projectionStore.getTurnStartHistory(input.threadId)).reduce((sum, item) => { @@ -1282,8 +1273,7 @@ export const layer: Layer.Layer< text: context === "" ? userText : `${context}\n\nUser message:\n${userText}`, }, }).pipe( - // A pending marker would make the next turn abandon this native - // session, though the refused prompt never reached it. + // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => refusedBeforePrompt(error) ? delivery.unsent.pipe( @@ -1296,8 +1286,7 @@ export const layer: Layer.Layer< : Effect.void, ), ); - // The provider already accepted the turn. A stale pending marker - // can force a fresh thread later, but must not stop live ingestion. + // After provider acceptance, failed delivery bookkeeping must not stop live ingestion. yield* delivery.delivered.pipe( Effect.catchCause(() => Effect.logWarning("Failed to record accepted context handoff delivery", { diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 09424a2a91ddd..028e74b06a2ca 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -332,17 +332,22 @@ function runUpdated( }; } -function commandRan(threadId: ThreadId, input: string): OrchestrationV2DomainEvent { +function commandRan( + threadId: ThreadId, + input: string, + output?: string, + name = "command", +): OrchestrationV2DomainEvent { const runId = RunId.make(`run:${threadId}:1`); const at = DateTime.makeUnsafe(NOW); return { type: "turn-item.updated", - id: EventId.make(`event:${threadId}:command`), + id: EventId.make(`event:${threadId}:${name}`), threadId, runId, occurredAt: at, payload: { - id: TurnItemId.make(`item:${threadId}:command`), + id: TurnItemId.make(`item:${threadId}:${name}`), threadId, runId, nodeId: null, @@ -358,6 +363,7 @@ function commandRan(threadId: ThreadId, input: string): OrchestrationV2DomainEve updatedAt: at, type: "command_execution", input, + ...(output === undefined ? {} : { output }), }, }; } @@ -466,6 +472,29 @@ const requestAndApply = Effect.fn("requestPullRequestSyncHarness")(function* ( yield* applyRecordedSyncs(fixture); }); +/** A thread with an open pull request whose merge command marks the end of earlier events. */ +const MERGING_THREAD = ThreadId.make("merging"); + +/** The link commands `events` dispatch, read once the reactor has handled them all. */ +const linksFrom = Effect.fn("linksFrom")(function* ( + fixture: Harness, + reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"], + events: ReadonlyArray, +) { + yield* Ref.set(fixture.linkCommands, []); + yield* Queue.offerAll(fixture.domainEvents, [ + ...events, + commandRan(MERGING_THREAD, "gh pr merge 7", undefined, "merge"), + ]); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + return yield* Ref.get(fixture.linkCommands); +}); + +const mergingSnapshot = () => + makeSnapshot([makeThread(MERGING_THREAD, { pullRequests: [makeLink(7, { state: "open" })] })]); + describe("PullRequestSyncReactor", () => { it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( @@ -859,7 +888,6 @@ describe("PullRequestSyncReactor", () => { yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); const firstSweep = yield* Ref.get(fixture.syncCommands); - // The held merged summary is dropped so the read reaches the host. assert.deepStrictEqual(yield* Ref.get(invalidated), [1]); assert.strictEqual(firstSweep.at(-1)?.snapshot.mergedAt, "2026-08-27T00:00:00.000Z"); yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); @@ -1341,7 +1369,7 @@ describe("PullRequestSyncReactor", () => { "gh pr merge 7 --squash 2>&1 | tail -3", "cafe pr merge 7 --squash", "az repos pr update --id 7 --status completed", - ])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) => + ])("reads open links fresh only for a thread that ran `%s`", (mergeCommand) => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1393,4 +1421,271 @@ describe("PullRequestSyncReactor", () => { }), ), ); + + it.effect("reads open links again when the run ends, after `merge --auto` left them open", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7 --auto"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.syncCommands, []); + + yield* Queue.offer(fixture.domainEvents, runUpdated(ThreadId.make("agent"), "completed")); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("reads open links fresh as soon as a merge command ends, before its run ends", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make("open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), + ]), + summary: (input) => + Ref.get(state).pipe( + Effect.map((current) => + makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set(state, "merged"); + yield* Ref.set(fixture.summaryCalls, []); + + yield* Queue.offer( + fixture.domainEvents, + commandRan(ThreadId.make("agent"), "gh pr merge 7"), + ); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => [ + command.number, + command.snapshot.state, + ]), + [[7, "merged"]], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect.each([ + { + name: "a plain create", + input: + 'gh pr create --base main --head feature --title "fix: 7 things" --body-file /tmp/body.md', + output: "https://github.com/owner/repository/pull/124\n", + linked: [124], + }, + { + name: "a Codex shell wrapper after a push", + input: '/bin/zsh -lc "git push -u origin feature 2>&1 | tail -2 && gh pr create --fill"', + output: [ + "remote: Create a pull request for 'feature' on GitHub by visiting:", + "remote: https://github.com/owner/repository/pull/new/feature", + "https://github.com/owner/repository/pull/124", + ].join("\n"), + linked: [124], + }, + { + name: "a Codex script quoted in pieces around a heredoc", + input: + "/bin/zsh -lc \"cat > /tmp/body.md <<'EOF'\nSet the \"'`hidden`'\" flag.\nEOF\ngh pr create --fill --body-file /tmp/body.md\"", + output: "https://github.com/owner/repository/pull/62\n", + linked: [62], + }, + { + name: "an older pull request read in the same command", + input: "gh pr view 120 --json url --jq .url && gh pr create --fill", + output: + "https://github.com/owner/repository/pull/120\nhttps://github.com/owner/repository/pull/124", + linked: [124], + }, + { + name: "both layers of a stack", + input: "gh pr create --base main --head a --fill && gh pr create --base a --head b --fill", + output: + "https://github.com/owner/repository/pull/124\nhttps://github.com/owner/repository/pull/125", + linked: [124, 125], + }, + { + name: "the first pull request of a new repository", + input: "cargo test 2>&1 | tail -1 && timeout 60 gh pr create -R owner/repository --fill", + output: "test result: ok\nhttps://github.com/owner/repository/pull/1", + linked: [1], + }, + { + name: "a GitLab merge request", + input: "glab mr create --fill --yes", + output: + "!3 Fix the thing (feature)\n https://gitlab.com/owner/repository/-/merge_requests/3\n", + linked: [3], + }, + { + name: "a new pull request whose number appears in the create command", + input: "timeout 60 gh pr create --fill", + output: "https://github.com/owner/repository/pull/60", + linked: [60], + }, + { + name: "a create subcommand that does not end its shell word", + input: "gh pr create#typo; gh pr view --json url --jq .url", + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create after an empty heredoc", + input: "cat > /tmp/notes.md <<'EOF'\nEOF\ngh pr create --fill", + output: "https://github.com/owner/repository/pull/124", + linked: [124], + }, + { + name: "a read with no create", + input: "gh pr view 124 --json url --jq .url", + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create command quoted in another program's query", + input: `sqlite3 audit.db "select output from items where input like '%gh pr create%'"`, + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create command with a separator inside quotes", + input: "echo 'example: gh pr create; gh pr create --fill' && gh pr view --json url --jq .url", + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "more URLs than create commands", + input: "gh pr view --json url --jq .url && gh pr create --fill", + output: + "https://github.com/owner/repository/pull/120\nhttps://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create command only in heredoc text", + input: + "cat > notes.md <<'EOF'\nOpen it with gh pr create.\nEOF\ngh pr list --json url --jq '.[].url'", + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create command after a comment that starts at a separator", + input: "gh pr view --json url --jq .url;# note; gh pr create --fill", + output: "https://github.com/owner/repository/pull/124", + linked: [], + }, + { + name: "a create beside a command that lists other pull requests", + input: "gh pr list --json url --jq '.[].url' && gh pr create --fill > /dev/null", + output: "https://github.com/owner/repository/pull/120", + linked: [], + }, + { + name: "another host's URL beside a create command", + input: "gh pr create --fill", + output: "https://gitlab.com/owner/repository/-/merge_requests/3", + linked: [], + }, + { + name: "an existing pull request quoted in another command", + input: + "printf '%s\\n' 'https://github.com/owner/repository/pull/120'; gh pr create --fill > /dev/null", + output: "https://github.com/owner/repository/pull/120", + linked: [], + }, + ])("links what the command created: $name", ({ input, output, linked }) => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ snapshot: mergingSnapshot() }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const child = ThreadId.make("delegated-child"); + const links = yield* linksFrom(fixture, reactor, [commandRan(child, input, output)]); + + assert.deepStrictEqual( + links.map((command) => [command.threadId, command.number, command.source]), + linked.map((number) => [child, number, "created"]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("links a created pull request once its command settles, with one command id", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ snapshot: mergingSnapshot() }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const settled = commandRan( + ThreadId.make("agent"), + "gh pr create --fill", + "https://github.com/owner/repository/pull/124", + ); + assert.strictEqual(settled.type, "turn-item.updated"); + if (settled.type !== "turn-item.updated") return; + + const links = yield* linksFrom(fixture, reactor, [ + { ...settled, payload: { ...settled.payload, status: "running" } }, + settled, + settled, + ]); + + assert.deepStrictEqual( + links.map((command) => command.number), + [124, 124], + ); + assert.strictEqual(links[0]!.commandId, links[1]!.commandId); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 0046bd879a85d..cc3225315620f 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -1,5 +1,7 @@ import { CommandId, + isOrchestrationV2WorkActive, + type OrchestrationV2TurnItem, type PullRequestSummary, type ThreadId, type ThreadPullRequestKey, @@ -48,6 +50,127 @@ const isChangeRequestCloseCommand = (command: string) => (definition) => definition.changeRequestCloseCommand?.test(command) === true, ); +// Where a shell starts a command, past `NAME=value` assignments and wrappers like `timeout 60`. +// A create command anywhere else, as in `echo "gh pr create"`, is text, not a command that runs. +const COMMAND_START = + "(?:^|[;&|({`\\n]|\\b(?:do|then|else)\\s)\\s*(?:(?:\\w+=\\S*|env|time|nohup|timeout\\s+\\S+)\\s+)*"; +const CREATE_COMMANDS = sourceControlDefinitions.definitions.flatMap( + ({ kind, changeRequestCreateCommand: command }) => + command === undefined + ? [] + : // The subcommand must end its shell word: `gh pr create#x` runs another one. + [ + { + kind, + command, + run: new RegExp(`${COMMAND_START}${command.source}(?=$|[\\s;&|)])`, "gu"), + }, + ], +); +const createCommandCount = (code: string) => + CREATE_COMMANDS.reduce((count, { run }) => count + [...code.matchAll(run)].length, 0); + +// Codex runs each command as `/bin/zsh -lc `; - // Saved captures begin with the CSP inserted by injectMcpAppCsp. Keep it - // ahead of executable code, including this bootstrap, without rewriting it. + // Preserve captured CSP ahead of executable code, including this bootstrap. const prefix = /^(?:\uFEFF?\s*]*>\s*)?(?:)?/i.exec( html, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d60d34190e803..4e64d5a0a75ec 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -631,10 +631,7 @@ export function applyClaudePromptEffortPrefix( if (!trimmed) { return trimmed; } - // Prefixing a slash command turns it into plain prose, so Claude never - // runs it. Command names come from arbitrary file names ("/deploy.prod", - // "/plugin:skill"), so accept any first token without a second slash; - // absolute paths like "/home/theo/app.ts" keep the prefix. + // Prefixing slash commands prevents Claude invocation; command names allow any token without a second slash. if (effort !== "ultrathink" || /^\/[^\s/]+(?:\s|$)/u.test(trimmed)) { return trimmed; } diff --git a/packages/shared/src/nodeRuntime.ts b/packages/shared/src/nodeRuntime.ts index 9bc70b7f25e10..7b3e9398682cc 100644 --- a/packages/shared/src/nodeRuntime.ts +++ b/packages/shared/src/nodeRuntime.ts @@ -28,19 +28,11 @@ export class NodeRuntimeUnavailableError extends Schema.TaggedError` SELECT value FROM counters WHERE id = 1 `; - // With a deferred BEGIN this commit lands and the write below fails - // with SQLITE_BUSY_SNAPSHOT, which no busy timeout can wait out. + // Deferred BEGIN permits a competing commit, causing SQLITE_BUSY_SNAPSHOT that no timeout can resolve. yield* Effect.sync(() => assert.throws( () => other.exec("UPDATE counters SET value = value + 1 WHERE id = 1"), diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts index 59ef57e59fe60..a5b65ddb1a03e 100644 --- a/packages/shared/src/nodeSqliteClient.ts +++ b/packages/shared/src/nodeSqliteClient.ts @@ -37,14 +37,10 @@ export interface SqliteClientConfig { readonly spanAttributes?: Record | undefined; readonly transformResultNames?: ((str: string) => string) | undefined; readonly transformQueryNames?: ((str: string) => string) | undefined; - /** - * Ignored for in-memory databases, which a second connection cannot share. - */ + /** Ignored for in-memory databases, which cannot share a second connection. */ readonly readerWorker?: boolean | undefined; readonly readerStartupTimeout?: Duration.Input | undefined; - /** - * Ignored for in-memory databases. - */ + /** Ignored for in-memory databases. */ readonly checkpointWorker?: boolean | undefined; } @@ -54,12 +50,7 @@ const ReadOnly = Context.Reference( { defaultValue: () => undefined }, ); -/** - * Wrap the whole transaction, as in - * `sql.withTransaction(reads).pipe(readOnly)`. Inside an open write transaction - * the reads keep using the writer, and a write attempted on the reader fails - * with "attempt to write a readonly database". - */ +/** Wrap the whole transaction; existing write transactions retain the writer, and writes on the reader fail as readonly. */ export const readOnly = (effect: Effect.Effect): Effect.Effect => Effect.uninterruptibleMask((restore) => restore(Effect.provideService(effect, ReadOnly, restore)), @@ -157,10 +148,7 @@ const checkNodeSqliteCompat = () => { return Effect.void; }; -/** - * `node:sqlite` reports the SQLite result code as `errcode`, while - * `classifySqliteError` reads `errno`. - */ +// node:sqlite supplies errcode; classifySqliteError expects errno. const classifyError = (cause: unknown, message: string, operation: string) => { if ( Predicate.hasProperty(cause, "errcode") && @@ -172,10 +160,7 @@ const classifyError = (cause: unknown, message: string, operation: string) => { return classifySqliteError(cause, { message, operation }); }; -/** - * SQLite keeps its copy of a statement's text and blob parameters until the - * statement is bound again or finalized, and `node:sqlite` cannot clear them. - */ +// SQLite retains text/blob bindings until rebind or finalization; node:sqlite cannot clear them. const LARGE_BINDING_SIZE = 64 * 1024; const isBlob = (value: unknown): value is ArrayBufferView | ArrayBufferLike => @@ -197,8 +182,7 @@ const hasLargeBinding = (params: ReadonlyArray): boolean => { param !== null && !isBlob(param) ) { - // `node:sqlite` takes named parameters as one object, callable or not. - // Read data properties only: calling a getter here would run it twice. + // Named parameters may be callable objects; read data properties only to avoid invoking getters twice. for (const property of Object.values(Object.getOwnPropertyDescriptors(param))) { if (!property.enumerable) continue; if (!("value" in property)) return true; @@ -214,8 +198,7 @@ const hasLargeBinding = (params: ReadonlyArray): boolean => { } }; -// Evaluated source instead of a worker file, so the reader also starts from -// bundles and single-executable builds that have no module file on disk. +// Inline worker source also runs in bundles and single-executable builds without module files. const readerWorkerSource = ` const { parentPort, workerData } = require("node:worker_threads"); const { DatabaseSync } = require("node:sqlite"); @@ -312,15 +295,10 @@ const readerFailureMessages = { const failed = (cause: unknown, message: string, operation: string) => new SqlError({ reason: classifyError(cause, message, operation) }); -// COMMIT, ROLLBACK and ROLLBACK TO SAVEPOINT. On a reader they change nothing, -// so they succeed once the worker is gone instead of failing the cleanup. +// Read-transaction cleanup succeeds after worker exit because it can no longer change anything. const endsReadTransaction = (sql: string) => /^\s*ROLLBACK\b|^\s*COMMIT\b/i.test(sql); -/** - * Each worker gets its own `Connection`, so a transaction stays on the worker - * it began on: once that worker exits, its statements fail rather than silently - * continuing outside the transaction on a replacement. - */ +// Bind transactions to one worker; fail after its exit instead of resuming outside the transaction on a replacement. const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { let current: | { @@ -432,8 +410,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { return request({ sql, params: params ?? [], cached: false, values: true, raw: false }); }, executeUnprepared(sql, params, rowTransform) { - // A read-only connection rejects BEGIN IMMEDIATE and BEGIN EXCLUSIVE, - // and a deferred BEGIN is all a read snapshot needs. + // Read snapshots need deferred BEGIN; readonly connections reject IMMEDIATE and EXCLUSIVE. const statement = /^\s*BEGIN\b/i.test(sql) ? "BEGIN" : sql; const effect = request({ sql: statement, @@ -494,8 +471,7 @@ const makeReader = Effect.fnUntraced(function* (options: SqliteClientConfig) { ); }), (startup): Effect.Effect => - // Another acquire may have timed out and retired this worker while this one - // waited; a late "ready" must not hand out a worker that is being terminated. + // A late ready event must not hand out a worker retired by another acquire timeout. unavailable || current !== reader ? Effect.succeed(undefined) : Option.isSome(startup) && startup.value.type === "ready" @@ -1220,9 +1196,7 @@ const make = Effect.fn("makeWithDatabase")(function* ( borrower, compiler, transactionAcquirer, - // A deferred BEGIN only takes the write lock at the first write. If another - // process commits after this transaction's first read, that write fails at - // once with SQLITE_BUSY_SNAPSHOT, which busy_timeout cannot wait out. + // BEGIN IMMEDIATE avoids read-then-write SQLITE_BUSY_SNAPSHOT, which busy_timeout cannot resolve. beginTransaction: options.readonly === true ? "BEGIN" : "BEGIN IMMEDIATE", spanAttributes: [ ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 25b9b3a746c31..241822a302acd 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -299,10 +299,7 @@ function truncateNestedValue(value: unknown): unknown { return value; } -/** - * Returns a new record when anything was clamped; never mutates the input (the live span's - * attributes are shared with other tracers). - */ +/** Clones only when clamping; never mutates attributes shared with other tracers. */ export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes { let truncated: Record | undefined; for (const [key, value] of Object.entries(attributes)) { @@ -402,9 +399,7 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac const startedAt = performance.now(); const written = yield* sink.write(chunk).pipe(Effect.result); if (Result.isFailure(written)) { - // A failing disk (ENOSPC, EACCES, EIO) drops the rest of the batch. - // Retrying it would grow the backlog, and every later push would - // retry all of it. + // Drop the batch after disk failure to avoid an ever-growing retry backlog. writeFailing = true; droppedCount += records.length - persistedCount; return; @@ -452,12 +447,10 @@ export const makeTraceSink = Effect.fn("makeTraceSink")(function* (options: Trac }), ) .pipe( - // A cancelled write still completes in the OS. Finish it before another - // flush rotates the file or the scope's finalizer drains the remaining records. + // Cancelled OS writes still finish; await completion before rotation or finalizer drain. Effect.uninterruptible, Effect.withTracerEnabled(false), - // The timed fiber inherits the makeTraceSink span. That span has ended but - // lives as long as the sink, so the tracer logger must not add logs to it. + // Drop the ended makeTraceSink parent span so timed-fiber logs cannot accumulate on it. Effect.updateContext(Context.omit(Tracer.ParentSpan)), ); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 53c9d9a6dc76f..cbb3c64a4bb05 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -62,12 +62,7 @@ function isLatestRunSettled( return true; } -/** - * The projector only stamps `startedAt` in the same update that moves the - * session to "running", so while the provider spins up (session "starting") a - * requested turn has no `startedAt` at all — and returning null there blinks - * the indicator out for the whole spin-up. - */ +/** Fall back during provider startup; the projector stamps startedAt only when the session becomes running. */ export function deriveActiveWorkStartedAt( latestRun: LatestRunTiming | null, runtime: RuntimeActivityState | null, diff --git a/packages/shared/src/orchestrationV2BoundedSnapshot.ts b/packages/shared/src/orchestrationV2BoundedSnapshot.ts index 599ccd5117e9c..ad246e38a60df 100644 --- a/packages/shared/src/orchestrationV2BoundedSnapshot.ts +++ b/packages/shared/src/orchestrationV2BoundedSnapshot.ts @@ -3,6 +3,8 @@ import type { OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import { restoreSnapshotBodies } from "./orchestrationV2DedupedBodies.ts"; + function isLocalTimelineRow( projection: Pick, row: OrchestrationV2ProjectedTurnItem, @@ -16,8 +18,7 @@ export function omitLocalVisibleTurnItems( let omitted = 0; for (const row of projection.visibleTurnItems) { if (!isLocalTimelineRow(projection, row)) continue; - // Same object, not merely the same id: restoring from the row must - // reproduce the original entry exactly. + // Require object identity so row restoration reproduces the exact entry. if (projection.turnItems[omitted] !== row.item) return null; omitted += 1; } @@ -25,10 +26,7 @@ export function omitLocalVisibleTurnItems( return { ...projection, turnItems: projection.turnItems.slice(omitted) }; } -/** - * Client half: call on a decoded snapshot that carries the - * `turnItemsOmitLocalVisible` marker, before reducers or caches see it. - */ +/** Restore marked snapshots before reducers or caches see them. */ export function restoreLocalVisibleTurnItems( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadProjection { @@ -39,11 +37,14 @@ export function restoreLocalVisibleTurnItems( return { ...projection, turnItems: [...local, ...projection.turnItems] }; } +/** Undoes both opt-in compactions; bodies carry their own markers, so they need no flag. */ export function boundedSnapshotProjection(snapshot: { readonly projection: OrchestrationV2ThreadProjection; readonly turnItemsOmitLocalVisible?: true | undefined; }): OrchestrationV2ThreadProjection { - return snapshot.turnItemsOmitLocalVisible === true - ? restoreLocalVisibleTurnItems(snapshot.projection) - : snapshot.projection; + return restoreSnapshotBodies( + snapshot.turnItemsOmitLocalVisible === true + ? restoreLocalVisibleTurnItems(snapshot.projection) + : snapshot.projection, + ); } diff --git a/packages/shared/src/orchestrationV2DedupedBodies.test.ts b/packages/shared/src/orchestrationV2DedupedBodies.test.ts new file mode 100644 index 0000000000000..a0f81bbd4d4d9 --- /dev/null +++ b/packages/shared/src/orchestrationV2DedupedBodies.test.ts @@ -0,0 +1,196 @@ +import { + CheckpointId, + CheckpointScopeId, + MessageId, + NodeId, + ThreadId, + type OrchestrationV2ThreadProjection, + TurnItemId, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + boundedSnapshotProjection, + omitLocalVisibleTurnItems, +} from "./orchestrationV2BoundedSnapshot.ts"; +import { + dedupeSnapshotBodies, + restoreSnapshotBodies, + restoreSubagentBodies, +} from "./orchestrationV2DedupedBodies.ts"; + +const THREAD = ThreadId.make("thread:bodies"); +const PARENT = ThreadId.make("thread:bodies-parent"); +const at = "2026-10-01T00:00:00.000Z"; + +function item(id: string, fields: Record, threadId = THREAD) { + return { + id: TurnItemId.make(id), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + ...fields, + } as unknown as OrchestrationV2TurnItem; +} + +const message = (id: string, role: "user" | "assistant", text: string) => ({ + id: MessageId.make(id), + threadId: THREAD, + runId: null, + nodeId: null, + role, + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? "user" : "agent", + creationSource: role === "user" ? "web" : "provider", + createdAt: at, + updatedAt: at, +}); +const files = [{ path: "src/a.ts", kind: "modified", additions: 3, deletions: 1 }]; +const subagent = { + id: NodeId.make("node:task"), + prompt: "do the task", + result: "final result", +} as Partial; + +const userMessage = (id: string, messageId: string, text: string) => + item(id, { + type: "user_message", + messageId: MessageId.make(messageId), + inputIntent: "turn_start", + text, + attachments: [], + createdBy: "user", + creationSource: "web", + }); +const assistantMessage = (id: string, messageId: string, text: string, threadId = THREAD) => + item( + id, + { type: "assistant_message", messageId: MessageId.make(messageId), text, streaming: false }, + threadId, + ); +const checkpoint = (id: string, checkpointId: string, itemFiles: typeof files) => + item(id, { + type: "checkpoint", + checkpointId: CheckpointId.make(checkpointId), + scopeId: CheckpointScopeId.make("scope"), + files: itemFiles, + }); + +const local: OrchestrationV2TurnItem[] = [ + userMessage("user", "m-user", "hello"), + assistantMessage("assistant", "m-assistant", "hi there"), + assistantMessage("stale", "m-stale", "older text"), + userMessage("empty", "m-empty", ""), + checkpoint("checkpoint", "c-1", files), + checkpoint("orphan-checkpoint", "c-missing", files), + checkpoint("no-files", "c-empty", []), + item("task", { + type: "subagent", + subagentId: subagent.id, + origin: "app_owned", + driver: "codex", + providerInstanceId: "codex", + childThreadId: null, + prompt: "do the task", + result: "partial result", + }), +]; +// A fork's inherited row may name a message id this thread also has; its record lives elsewhere. +const inherited = assistantMessage("inherited", "m-assistant", "hi there", PARENT); + +const projection = { + thread: { id: THREAD }, + messages: [ + message("m-user", "user", "hello"), + message("m-assistant", "assistant", "hi there"), + message("m-stale", "assistant", "newer text"), + message("m-empty", "user", ""), + ], + checkpoints: [ + { id: CheckpointId.make("c-1"), files }, + { id: CheckpointId.make("c-empty"), files: [] }, + ], + subagents: [subagent], + turnItems: local, + visibleTurnItems: [ + { + position: 0, + visibility: "inherited", + sourceThreadId: PARENT, + sourceItemId: inherited.id, + item: inherited, + }, + ...local.map((value, index) => ({ + position: index + 1, + visibility: "local", + sourceThreadId: THREAD, + sourceItemId: value.id, + item: value, + })), + ], +} as unknown as OrchestrationV2ThreadProjection; + +function markers(value: OrchestrationV2ThreadProjection) { + return Object.fromEntries( + value.visibleTurnItems.flatMap(({ item }) => { + const marked = ["textOmitted", "filesOmitted", "promptOmitted", "resultOmitted"].filter( + (key) => key in item, + ); + return marked.length === 0 ? [] : [[String(item.id), marked]]; + }), + ); +} + +describe("deduped snapshot bodies", () => { + it("blanks only bodies equal to a record and restores them exactly", () => { + const deduped = dedupeSnapshotBodies(projection); + + expect(markers(deduped)).toEqual({ + user: ["textOmitted"], + assistant: ["textOmitted"], + checkpoint: ["filesOmitted"], + task: ["promptOmitted"], + }); + expect(restoreSnapshotBodies(deduped)).toEqual(projection); + expect(restoreSnapshotBodies(projection)).toBe(projection); + }); + + it("keeps items shared with turnItems as one object, so the compact snapshot still applies", () => { + const compact = omitLocalVisibleTurnItems(dedupeSnapshotBodies(projection)); + + expect(compact?.turnItems).toEqual([]); + expect( + boundedSnapshotProjection({ projection: compact!, turnItemsOmitLocalVisible: true }), + ).toEqual(projection); + }); + + it("does not restore a field from a subagent record whose field is itself still marked", () => { + const marked = { + prompt: "", + result: null, + promptOmitted: true as const, + resultOmitted: true as const, + }; + const previous = { prompt: "", result: "done", promptOmitted: true as const }; + + expect(restoreSubagentBodies(marked, previous)).toEqual({ + prompt: "", + result: "done", + promptOmitted: true, + }); + }); +}); diff --git a/packages/shared/src/orchestrationV2DedupedBodies.ts b/packages/shared/src/orchestrationV2DedupedBodies.ts new file mode 100644 index 0000000000000..701ae1e830556 --- /dev/null +++ b/packages/shared/src/orchestrationV2DedupedBodies.ts @@ -0,0 +1,184 @@ +import { + OrchestrationV2CheckpointFileSummary, + type CheckpointId, + type MessageId, + type NodeId, + type OrchestrationV2Checkpoint, + type OrchestrationV2Subagent, + type OrchestrationV2ThreadProjection, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +/** + * For clients that opt in, the server blanks a turn item's body that its record already carries + * (message text, checkpoint files, a subagent's prompt and result) and marks the field. The client + * copies the value back from the record. Only a body equal to the record's is blanked, so the + * restore is exact. + */ +export interface DedupedBodySource { + readonly messageText: (id: MessageId) => string | undefined; + readonly checkpointFiles: (id: CheckpointId) => OrchestrationV2Checkpoint["files"] | undefined; + readonly subagent: (id: NodeId) => WithSubagentBodies | undefined; +} + +type SubagentBodies = Pick; +type WithSubagentBodies = SubagentBodies & { + readonly promptOmitted?: true; + readonly resultOmitted?: true; +}; + +const sameCheckpointFiles = Schema.toEquivalence( + Schema.Array(OrchestrationV2CheckpointFileSummary), +); + +/** For subagent items and for a repeated `subagent.updated`, whose `previous` was sent earlier. */ +export function dedupeSubagentBodies( + value: T, + previous: SubagentBodies | undefined, +): T { + const prompt = value.prompt !== "" && previous?.prompt === value.prompt; + const result = value.result !== null && previous?.result === value.result; + if (!prompt && !result) return value; + return { + ...value, + ...(prompt ? { prompt: "", promptOmitted: true as const } : {}), + ...(result ? { result: null, resultOmitted: true as const } : {}), + }; +} + +/** + * A marked body whose record is missing, or whose record field is itself still marked, stays + * blank and marked; the server only blanks bodies it sent, so either means a server bug. + */ +export function restoreSubagentBodies( + value: T, + previous: WithSubagentBodies | undefined, +): T { + if (previous === undefined) return value; + const prompt = value.promptOmitted === true && previous.promptOmitted !== true; + const result = value.resultOmitted === true && previous.resultOmitted !== true; + if (!prompt && !result) return value; + const { promptOmitted, resultOmitted, ...rest } = value; + // Dropping both optional markers from `T` and keeping the unrestored one leaves a `T`. + return { + ...rest, + ...(prompt ? { prompt: previous.prompt } : promptOmitted === true ? { promptOmitted } : {}), + ...(result ? { result: previous.result } : resultOmitted === true ? { resultOmitted } : {}), + } as T; +} + +export function dedupeTurnItemBodies( + item: OrchestrationV2TurnItem, + source: DedupedBodySource, +): OrchestrationV2TurnItem { + switch (item.type) { + case "user_message": + case "assistant_message": + return item.text !== "" && source.messageText(item.messageId) === item.text + ? { ...item, text: "", textOmitted: true } + : item; + case "checkpoint": { + const files = source.checkpointFiles(item.checkpointId); + return item.files.length > 0 && files !== undefined && sameCheckpointFiles(files, item.files) + ? { ...item, files: [], filesOmitted: true } + : item; + } + case "subagent": + return dedupeSubagentBodies(item, source.subagent(item.subagentId)); + default: + return item; + } +} + +export function restoreTurnItemBodies( + item: OrchestrationV2TurnItem, + source: DedupedBodySource, +): OrchestrationV2TurnItem { + switch (item.type) { + case "user_message": + case "assistant_message": { + const text = item.textOmitted === true ? source.messageText(item.messageId) : undefined; + if (text === undefined) return item; + const { textOmitted: _textOmitted, ...rest } = item; + return { ...rest, text }; + } + case "checkpoint": { + const files = + item.filesOmitted === true ? source.checkpointFiles(item.checkpointId) : undefined; + if (files === undefined) return item; + const { filesOmitted: _filesOmitted, ...rest } = item; + return { ...rest, files }; + } + case "subagent": + return item.promptOmitted === true || item.resultOmitted === true + ? restoreSubagentBodies(item, source.subagent(item.subagentId)) + : item; + default: + return item; + } +} + +/** Builds each index on first lookup, so a snapshot without marked items builds none. */ +function snapshotBodySource(projection: OrchestrationV2ThreadProjection): DedupedBodySource { + let messages: Map | undefined; + let checkpoints: Map | undefined; + let subagents: Map | undefined; + return { + messageText: (id) => + (messages ??= new Map(projection.messages.map((message) => [message.id, message.text]))).get( + id, + ), + checkpointFiles: (id) => + (checkpoints ??= new Map( + projection.checkpoints.map((checkpoint) => [checkpoint.id, checkpoint.files]), + )).get(id), + subagent: (id) => + (subagents ??= new Map(projection.subagents.map((subagent) => [subagent.id, subagent]))).get( + id, + ), + }; +} + +/** + * Inherited rows belong to another thread, whose records this projection lacks. Maps each item + * once, so an item shared by `visibleTurnItems` and `turnItems` stays one object. + */ +function mapLocalTurnItems( + projection: OrchestrationV2ThreadProjection, + map: (item: OrchestrationV2TurnItem) => OrchestrationV2TurnItem, +): OrchestrationV2ThreadProjection { + const mapped = new Map(); + let changed = false; + const once = (item: OrchestrationV2TurnItem) => { + let next = mapped.get(item); + if (next === undefined) { + next = map(item); + mapped.set(item, next); + changed ||= next !== item; + } + return next; + }; + const visibleTurnItems = projection.visibleTurnItems.map((row) => { + if (row.visibility !== "local" && row.sourceThreadId !== projection.thread.id) return row; + const item = once(row.item); + return item === row.item ? row : { ...row, item }; + }); + const turnItems = projection.turnItems.map(once); + return changed ? { ...projection, visibleTurnItems, turnItems } : projection; +} + +export function dedupeSnapshotBodies( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const source = snapshotBodySource(projection); + return mapLocalTurnItems(projection, (item) => dedupeTurnItemBodies(item, source)); +} + +/** Call on every decoded snapshot before reducers or caches see it. */ +export function restoreSnapshotBodies( + projection: OrchestrationV2ThreadProjection, +): OrchestrationV2ThreadProjection { + const source = snapshotBodySource(projection); + return mapLocalTurnItems(projection, (item) => restoreTurnItemBodies(item, source)); +} diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 8fcf478fb7621..1d82ee14bb67b 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -6,6 +6,17 @@ import { runningDelegatedTasks, } from "./orchestrationV2PendingBackgroundWork.ts"; +describe("backgroundWorkHoldsCompletion", () => { + it.each([ + ["a dev server", false, [{ taskId: "dev", kind: "command" }]], + ["a dev server's watchdog monitor", false, [{ taskId: "watch", kind: "monitor" }]], + ["a command the agent waits on", true, [{ taskId: "tests", kind: "command", awaited: true }]], + ["a subagent", true, [{ taskId: "review", kind: "subagent" }]], + ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { + expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); + }); +}); + describe("derivePendingBackgroundWork", () => { it("takes each subagent item's instance from its joined owner", () => { const owner = ProviderInstanceId.make("claude-owner"); @@ -310,6 +321,7 @@ describe("derivePendingBackgroundWork kinds", () => { id: "pt-1" as never, pendingBackgroundTasks: [ { taskId: "bash", kind: "command" }, + { taskId: "tests", kind: "command", awaited: true }, { taskId: "watch", kind: "monitor" }, { taskId: "workflow", kind: "background_task" }, ], @@ -336,6 +348,7 @@ describe("derivePendingBackgroundWork kinds", () => { }); expect(tasks).toEqual([ { taskId: "bash", kind: "command" }, + { taskId: "tests", kind: "command", awaited: true }, { taskId: "watch", kind: "monitor" }, { taskId: "workflow", kind: "background_task" }, { @@ -377,7 +390,7 @@ describe("derivePendingBackgroundWork kinds", () => { link(3, { source: "stack-dismissed" }), ]; - it("keeps a settled run waiting on each visible watch, as a monitor", () => { + it("keeps a settled run waiting on each visible watch, as an awaited monitor", () => { const tasks = derivePendingBackgroundWork({ latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, providerThreads: [], @@ -389,6 +402,7 @@ describe("derivePendingBackgroundWork kinds", () => { taskId: "pull-request-watch:github.com/acme/app#1", description: "Watching pull request #1", kind: "monitor", + awaited: true, }, ]); expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 0206486af83c2..dfdb38d07c25c 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -34,12 +34,7 @@ const TERMINAL_RUN_STATUSES = new Set([ "rolled_back", ]); -/** - * Includes `waiting`: a successful turn persists as waiting until checkpoint capture flips it to - * completed, and waiting is only set from completed. Excludes `rolled_back`: the provider-thread - * roster is not run-tied, so a rolled-back latest run must fail the gate entirely (not only item - * filter). - */ +// waiting is completed work awaiting checkpoints; exclude rolled_back entirely because provider threads are not run-tied. const SETTLED_FOR_BACKGROUND_WAIT_RUN_STATUSES = new Set([ "cancelled", "completed", @@ -92,17 +87,21 @@ export function deriveBlockedOnThreadWait(input: { } export function backgroundWorkHoldsCompletion( - tasks: ReadonlyArray>, + tasks: ReadonlyArray>, ): boolean { - return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind)); + return tasks.some( + (task) => task.awaited === true || backgroundWorkKindHoldsCompletion(task.kind), + ); } +// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold +// only when the agent marks them awaited. function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { switch (kind) { case "command": + case "monitor": return false; case "subagent": - case "monitor": case "background_task": return true; } @@ -132,6 +131,7 @@ type PendingBackgroundWorkTurnItem = { readonly nativeId: string | null; } | null; readonly input?: unknown; + readonly monitor?: boolean | undefined; readonly prompt?: string | undefined; readonly childThreadId?: ThreadId | null; }; @@ -190,7 +190,7 @@ function pendingTaskFromTurnItem( ...(item.childThreadId == null ? {} : { childThreadId: item.childThreadId }), }; case "command_execution": - return { ...named, kind: "command" }; + return { ...named, kind: item.monitor === true ? "monitor" : "command" }; default: return { ...named, kind: "background_task" }; } @@ -204,10 +204,7 @@ function nativeTaskIdFromTurnItem(item: PendingBackgroundWorkTurnItem): string { return String(item.id); } -/** - * The turn items the pending-work list names, without its settled-run gate. - * Stop ends exactly these, so the list and Stop cannot disagree. - */ +/** Items Stop ends, without the pending list's settled-run gate. */ export function pendingBackgroundTurnItems(input: { readonly turnItems: ReadonlyArray; readonly runs?: ReadonlyArray; @@ -263,11 +260,7 @@ export function derivePendingBackgroundWork(input: { readonly turnItems: ReadonlyArray; readonly activeProviderThreadId?: string | null; readonly hasActiveRun?: boolean; - /** - * Run rows used to exclude items owned by rolled_back runs. Optional for - * callers that already filtered (SQL shell path); in-memory callers should - * pass projection runs so policy cannot drift. - */ + /** Excludes rolled-back runs; optional only when the caller already filtered them, as SQL shell reads do. */ readonly runs?: ReadonlyArray; readonly pullRequests?: ReadonlyArray | undefined; }): ReadonlyArray { @@ -338,6 +331,7 @@ function pullRequestWatchTasks( taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, + awaited: true as const, }, ], ); diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 4fa6cfe2b19cd..efe41f3a53044 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -64,12 +64,7 @@ export function latestExecutedRun( return latest; } -/** - * Ordinals follow submission, but a run can start ahead of a held queue (a - * restart continuation, or a message sent while the queue is held), so a queued - * run resumed later can have a lower ordinal than one that already ended. An - * unfinished run is the latest. - */ +/** Held queues can execute out of ordinal order; unfinished runs count as latest. */ export function runRanAfter( run: Pick, other: Pick, @@ -106,11 +101,7 @@ export function usageLimitBlockedRun( return summary.lastErrorClass === "usage_limit" ? executed : null; } -/** - * That limited run, when newer runs were queued or cancelled before starting. - * Callers that treat the highest-ordinal run as the thread outcome would - * otherwise hide the limit. - */ +/** Retains a limited run's outcome when higher-ordinal runs never started. */ export function usageLimitRunPresentedAsLatest( runs: ReadonlyArray, turnItems: ReadonlyArray, diff --git a/packages/shared/src/orchestrationV2Timeline.ts b/packages/shared/src/orchestrationV2Timeline.ts index d160b318a3e0d..6ca8a95f7fb5a 100644 --- a/packages/shared/src/orchestrationV2Timeline.ts +++ b/packages/shared/src/orchestrationV2Timeline.ts @@ -31,8 +31,7 @@ export function isOrchestrationV2SupersededInterrupt(input: { return false; } - // Paired stop-then-steer results have a matching request on the same run and - // must stay visible. Legacy plain-steer results have no request and stay hidden. + // Show paired stop-then-steer results; hide legacy plain-steer results without matching requests. const hasMatchingRequest = input.items.some( (candidate) => candidate.type === "run_interrupt_request" && candidate.runId === item.runId, ); @@ -53,9 +52,7 @@ export function isOrchestrationV2TurnItemVisible(input: { return false; } - // A queued message is composer state, not conversation history. When its run - // is cancelled the input never reached the provider, so it must not surface - // as a transcript row. + // Cancelled queued input never reached the provider and must stay out of the transcript. if ( item.type === "user_message" && item.inputIntent === "queued_turn" && diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts index 493987a4350f9..2d6cd480e2a56 100644 --- a/packages/shared/src/otelEnvironment.ts +++ b/packages/shared/src/otelEnvironment.ts @@ -257,9 +257,7 @@ const sdkFlags = { T3CODE_FALSE, (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`, ), - // The specification: a boolean it defines is true "only by the - // case-insensitive string `true`", implementations "MUST NOT" accept other - // values as true, and should warn about unrecognized ones. + // OTEL booleans are true only for case-insensitive true; warn on unrecognized values. spec: flag( "OTEL_SDK_DISABLED", ["true"], @@ -379,11 +377,7 @@ export const resolveSignalEndpoint = ( }); }; -/** - * Provide this around Effect's OTLP exporters, which read - * `OTEL_RESOURCE_ATTRIBUTES` for themselves and die when it does not decode, - * so they see what `load` accepted instead. - */ +/** Give OTLP exporters the accepted resource attributes so their own decoding cannot fail. */ export const layerResourceAttributes = (attributes: Readonly>) => ConfigProvider.layerAdd( ConfigProvider.fromEnv({ diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts index 1098978ba2ed1..f7788e58ecbf9 100644 --- a/packages/shared/src/paletteSearch.ts +++ b/packages/shared/src/paletteSearch.ts @@ -1,13 +1,4 @@ -/** - * Word-aware fuzzy ranking for the command palettes (web, desktop, mobile) and - * the MCP thread search, so every surface puts the same thread first. - * - * A match scores by quality: a whole word beats a word prefix, which beats - * words typed together, a one-typo prefix, and then a mid-word substring. - * Word starts include camelCase, kebab, path, and letter/digit boundaries. - * Thread scores then multiply quality by how recently and often the thread - * was used, within limits. - */ +/** Shared ranking keeps command palettes and MCP thread search consistent across surfaces. */ import type { OrchestrationV2ThreadOpens } from "@t3tools/contracts"; @@ -198,14 +189,9 @@ interface TokenMatch extends SearchRange { } /** - * Best match of one folded query token in a field. Tokens shorter than four - * characters only match at a word start ("del" should not find "model"), and - * longer ones only inside a word where `midWord` allows it. With - * `typos`, a three-character token may swap a pair and a longer one may carry - * one typo in a word prefix that keeps its first letter. Numbers never match - * with a typo: 22889 is not pull request 22189. In the primary field (the - * name), the token may also be whole words typed together (see - * `matchJoinedWords`). + * Mid-word hits need four or more characters and `midWord`, so "del" does not find "model". + * Typos preserve the first letter except for swaps; short tokens allow fewer edits to limit noise. + * Numbers never match with a typo because different pull request numbers must stay distinct. */ function matchToken( field: SearchField, @@ -249,7 +235,7 @@ function matchToken( if (joined) return joined; } if ((best !== null && best.score > MID_WORD) || !typos || token.length < 3) return best; - // Numbers skip typos: #22889 is not #22189. Words with digits ("t3code") do not. + // Numbers skip typos to keep pull requests distinct. Words with digits ("t3code") do not. if (LETTER.test(token)) { for (let index = 0; index < field.text.length; index++) { if (!field.wordStarts[index]) continue; @@ -285,11 +271,8 @@ function matchToken( } /** - * Whole words typed together: "vscode" is "VS Code", "fixsid" is "Fix - * sid(ebar)". Every part is a whole word but the last. Short words before a - * short prefix are a coincidence inside a title ("inth" is not "in th(read)"), - * but not at its start ("t3co" is "T3 Co(de)"), before three letters - * ("gomod"), or a whole word ("vscode"). + * Short words followed by a short prefix are noise inside a title ("inth" in "in thread"). + * Allow them at its start ("t3co"), before three letters ("gomod"), or a whole word ("vscode"). */ function matchJoinedWords(field: SearchField, token: string): TokenMatch | null { const firstWord = field.wordStarts.indexOf(1); @@ -446,11 +429,9 @@ function matchSearchFields( const MESSAGE_TOKEN_MIN_LENGTH = 3; /** - * Message search tokens, lowercased and NFC-composed but not stripped of - * accents: the server's index folds Latin accents itself, and stripping marks - * would break Korean and Japanese text. Every `long` token must appear in a - * message; `short` ones only narrow those hits ("pnpm 12"). A query with no - * long token searches no messages. Two CJK characters count as long. + * Keep accents: the server index folds Latin accents, and stripping marks breaks Korean/Japanese. + * Every `long` token must match; `short` tokens only narrow hits. No long tokens means no search. + * Two CJK characters count as long. */ export function messageSearchTokens(query: string): { readonly long: ReadonlyArray; @@ -507,7 +488,7 @@ const MESSAGE_PREFIX = 0.8; function startsWordAt(text: string, index: number): boolean { const character = text[index]!; - // A token that opens with punctuation (".ts", "#22889") brings its own boundary. + // A token that opens with punctuation (such as ".ts") brings its own boundary. if (!ALPHANUMERIC.test(character) && !SYMBOL.test(character)) return true; return isWordStart( text[index - 2] ?? "", @@ -821,11 +802,8 @@ function threadUsageBoost(thread: ThreadSearchSubject, now: number): number { const REFERENCE_PREFIX_MIN = 8; -// IDs and URLs split into many word starts, so matching them as fields would -// let a two-letter query hit every thread. Only a long piece of one matches: a -// prefix, or, when it holds a digit like an ID or pull request number, any -// part ("t3code#22889", a URL without its scheme). Both are ASCII, so -// lowercasing is enough. +// IDs and URLs have many word starts, so field matching would let short queries hit every thread. +// Require a long prefix, or any long part containing a digit. Both are ASCII; lowercasing suffices. function matchReference( normalizedQuery: string, thread: ThreadSearchSubject, @@ -851,12 +829,8 @@ function matchReference( : null; } -// How long a pick lasts. While it does, typing the query again (or the start of -// it) puts the thread first among its group's word matches, the exact query's -// pick and then the latest first. A pick never moves the group itself, so a -// thread picked once for "new" does not pass the command of that name. It runs -// from the pick, so an old pick lets go even if the thread is opened in other -// ways. +// Picks expire from the pick time; opening a thread another way must not extend their priority. +// They reorder word matches within a group, never the groups themselves. const PICK_WINDOW_MS = 21 * DAY_MS; // The start of a picked query counts from three letters: "n" is too little to // go on. @@ -998,8 +972,7 @@ export function scoreThreadSearch( if (thread.environmentLabel) { fields.push({ value: thread.environmentLabel, weight: 0.25, context: true }); } - // "#22889", "pr 22889", and a pasted "t3/branch" search without their - // prefixes, as the branch field is stored without the app's worktree prefix. + // Strip pull request prefixes and the worktree prefix so queries match the stored fields. const fieldQuery = normalizedQuery .replace(/^(?:pr|pull request|pull)\s*(?=#?\d)/, "") .replace(/^#\s*/, "") @@ -1052,13 +1025,7 @@ function messageMatchWeight(hits: number): number { return 1 + Math.log2(1 + hits); } -/** - * Orders message matches from the server, from any number of environments: - * word matches before matches inside words, then threads picked for the - * query, then match quality, then hit count times use, then the newest - * message. With `withoutTitleMatches`, threads that match by title are left - * out: a palette lists them above, so they must not use up message rows. - */ +/** `withoutTitleMatches` reserves message rows for threads the palette has not already listed. */ export function rankMessageMatches< T extends { readonly quality?: number; @@ -1112,12 +1079,7 @@ export const PALETTE_MESSAGE_LIMIT = 10; /** Next to matching threads, other groups show only their best few rows. */ export const PALETTE_SECONDARY_LIMIT = 3; -// The whole name typed (1.5 and up), every token at a word start of a main -// field (0.7 and up), a typo in a main field (0.4 and up), or weaker. Whole -// word against prefix is too fine a line for group order: "compact" must not -// lift a setting named "Compact…" over the thread "Compaction…". But the -// project named "lakebed" leads for "lakebed", and "keybindngs" is the setting -// of that name, not a command that lists it as a keyword. +// Whole words and prefixes share a class so "Compact" does not lift settings over "Compaction". function matchClass(quality: number): number { return quality >= WHOLE_NAME ? 3 : quality >= 0.7 ? 2 : quality >= 0.4 ? 1 : 0; } @@ -1128,20 +1090,8 @@ export interface RankedSearchItem { } /** - * Ranks grouped items: within a group, word matches before weaker ones, - * results picked for exactly the query first among them (the latest pick - * first), then by match class, then results picked for a query that starts - * with this one, then best score. Groups with no match are dropped. The rest order by the class of - * their best match (the whole name, word starts in a name, typos in a name or - * keyword matches, or weaker), and ties keep the given order. Classes - * rather than scores keep the group order steady while a word is typed, and - * keep use, which only counts for threads, from outweighing a command that - * matches as well. - * - * Typos are only tried when no result matches every token at a word start: - * next to a real match, typo matches are noise ("enter" finding "external"), - * but they should beat a mid-word accident ("ilde" in "Builder" for "idle"). - * Skipping them also keeps a keystroke cheap. + * Rank groups by match class to keep typing stable and thread use from outweighing equal commands. + * Try typos only without word-start matches: they add noise beside real matches and cost per key. */ export function rankSearchGroups< G, @@ -1173,11 +1123,9 @@ export function rankSearchGroups< return ranked .flatMap(({ group, ranked: items }, order) => { if (items.length === 0) return []; - // Use decides within a match class, never across one: a busy thread - // matched through its project does not pass a title that holds the words. - // Picks reorder word matches but never lift a match inside a word or a - // typo above one, and a pick for a longer query ("lakebed perf") does not - // pass the result whose whole name is the query ("Lakebed"). + // Use cannot lift a project-only match above a title match or cross match classes. + // Picks reorder word matches, keeping typos and mid-word hits below them. + // A longer-query pick must not pass a whole-name match for the current query. const wordMatch = (match: M) => Math.min(matchClass(match.quality), 2); const exactPickAt = (match: M) => (match.pickedExactly ? (match.pickedAt ?? 0) : 0); const sorted = [...items].sort( @@ -1223,11 +1171,8 @@ const KEYWORD_QUALITY = 0.65; const KEYWORD_QUERY_MIN_LENGTH = 4; /** - * Scores a non-thread result such as a command or project. The first term - * names it; the others are keywords that help it match but count less, so a - * keyword match ranks a group below a name match ("mcp" finds the thread - * named for it before a command with that keyword). Keywords match only at - * word starts, and for queries under four characters only as whole words. + * Keywords count less so hidden terms cannot lift a group above a matching name. + * Short queries need whole keywords to avoid listing most settings for common prefixes. */ export function scoreSearchTerms( normalizedQuery: string, diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 23a8cf3c6d401..5d678800816d3 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -31,10 +31,7 @@ export function isExplicitRelativePath(value: string): boolean { } function isRootPath(value: string): boolean { - // The drive separator is required: a bare `C:` is not the drive root (it - // means "current directory on C:"), and treating it as already-canonical - // would leave it as `C:` while `C:\` and `C:/` normalize to the drive root, - // so the same location would fail project identity/dedup comparisons. + // Bare C: means the current directory on C, not its root; require the drive separator. return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]$/.test(value); } diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index caf25aa12e0d7..4f337f5be3d55 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -153,9 +153,7 @@ export function resolveAddressBarInput( if (URL_SCHEME_WITH_AUTHORITY_PATTERN.test(trimmed) || trimmed.length === 0) return normalizePreviewUrl(trimmed); if (!/\s/.test(trimmed)) { - // `mailto:x` or `data:...` is an explicit scheme; `example.com:8080` or - // `devbox:8080` is a bare host and port. `normalizePreviewUrl` would read - // `mailto:alice@example.com` as credentials on a bare host, so reject here. + // Reject explicit schemes before URL normalization can misread mailto as bare-host credentials. const protocol = previewUrlProtocol(trimmed); if ( protocol !== undefined && diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index d1e066bee16d7..15e8d42d83f48 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -16,8 +16,7 @@ export interface PreviewViewportPreset { type PreviewViewportPresetDefinition = Omit; -// Keep this in Chrome DevTools' default-device order. Dimensions are CSS -// viewport sizes from Chromium's EmulatedDevices.ts standard catalog. +// Chromium EmulatedDevices.ts CSS viewport sizes in Chrome DevTools default-device order. const PREVIEW_VIEWPORT_PRESET_DEFINITIONS = { "iphone-se": { label: "iPhone SE", diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index b0a636a21bf6b..2f1140502561a 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -38,19 +38,13 @@ export function hasProjectSettingsOverrides( settings: Pick, ): boolean { for (const entry of Object.values(settings.projectSettingsOverrides)) { - // A forward-compatible decode can leave an unknown value as a present - // undefined; that is not an override. + // Forward-compatible decoding can leave a present undefined, which is not an override. if (Object.values(entry).some((value) => value !== undefined)) return true; } return false; } -/** - * The project aggregate's own model and workspace fields. They remain the - * source of truth until the server has folded them into the override record; - * after the fold the record alone decides, so a reset there cannot be undone - * by a stale aggregate value. - */ +/** Legacy aggregate fields apply only until folded into overrides, so stale values cannot undo later resets. */ export interface LegacyProjectSettingsFields { readonly defaultModelSelection?: ModelSelection | null | undefined; readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined; @@ -139,8 +133,7 @@ function resolveProjectOverrides( for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { if (!Object.hasOwn(overrides, key)) continue; const value = overrides[key]; - // A forward-compatible decode leaves an unknown value as a present - // undefined; that is not an override. + // Forward-compatible decoding can leave a present undefined, which is not an override. if (value === undefined) continue; if ( (key === "textGenerationModelSelection" || key === "defaultModelSelection") && @@ -172,12 +165,7 @@ function resolveProjectOverrides( return { settings: effective as ServerSettings, sources, overrides }; } -/** - * A project the environment made from just a name (a folder directly inside - * `ServerConfig.newProjectsRoot`) has only its first commit, so until it has a - * thread, a new thread works in the project checkout even when the environment - * default is a new worktree. - */ +/** New name-only projects use their checkout until the first thread, even when the default is worktree. */ export function resolveNewThreadEnvMode(input: { readonly projectSettings: ResolvedProjectSettings; readonly workspaceRoot: string | null; diff --git a/packages/shared/src/promptLinks.ts b/packages/shared/src/promptLinks.ts index d1328f0f4d3a2..56f85cb3ac6b0 100644 --- a/packages/shared/src/promptLinks.ts +++ b/packages/shared/src/promptLinks.ts @@ -34,14 +34,11 @@ export type PromptLinkResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; -// Zero-width and bidi controls, tag and variation-selector blocks, and other characters that -// render as nothing. A model reads them; the person checking the draft cannot see them, which -// makes them the usual way to hide instructions in a prompt. +// Hidden characters let models read instructions invisible to the person reviewing the prompt. const HIDDEN_CHARACTERS = // oxlint-disable-next-line no-control-regex, no-misleading-character-class -- these are the characters it removes. /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0D\uFEFF\uFFA0\uFFF0-\uFFFB\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]/gu; -// Emoji and the pieces emoji sequences are built from: pictographs, symbols, skin tones, and the -// emoji presentation selector. Numeric ranges, because Hermes lacks Unicode property escapes. +// Numeric emoji ranges because Hermes lacks Unicode property escapes. const isEmojiPart = (codePoint: number | undefined) => codePoint !== undefined && ((codePoint >= 0x1f000 && codePoint <= 0x1faff) || diff --git a/packages/shared/src/qrCode.ts b/packages/shared/src/qrCode.ts index 6677357273aa2..2ee139f24e54c 100644 --- a/packages/shared/src/qrCode.ts +++ b/packages/shared/src/qrCode.ts @@ -638,8 +638,7 @@ export class QrSegment { public readonly numChars: int; private readonly bitData: Array; - // The character count (numChars) must agree with the mode and the bit buffer length, - // but the constraint isn't checked. + // numChars must match the mode and bit-buffer length; not checked. public constructor( mode: QrSegmentMode, diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 10c098dad93c8..0a987832ff749 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -17,8 +17,7 @@ import * as HostProcess from "./HostProcess.ts"; import * as RelayClient from "./relayClient.ts"; -// The suite runs the linux code path against the real filesystem, checking -// POSIX exec bits that NTFS never reports; the win32 branch skips that check. +// POSIX exec-bit checks do not apply on NTFS. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; const layerHostRuntime = (env: Record = {}, platform: NodeJS.Platform = "linux") => diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 09a901d1068b3..4d78bb02093f4 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -25,9 +25,7 @@ import { isWindowsRenameLock } from "./atomicWrite.ts"; import * as HostProcess from "./HostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.10.0"; -// The oldest release that accepts every flag the connector is started with -// (`--output` arrived in 2025.6.1). Override, PATH, and older managed binaries -// below it are skipped, since they exit immediately on the unknown flag. +// Minimum for --output support; older connectors exit on that flag. export const CLOUDFLARED_MIN_VERSION = "2025.6.1"; const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; @@ -253,9 +251,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); - // `cloudflared version` costs a process spawn, so answers are cached per file - // revision. A failed or timed-out probe is not cached, so a slow spawn under - // an AV scan does not hide a good binary until it changes on disk. + // Cache successful version probes per file revision; do not let transient failures hide valid binaries. const versionCache = yield* Cache.makeWith( (key: VersionProbeKey) => spawner @@ -308,8 +304,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return { status: "available", executablePath, source, version } satisfies AvailableRelayClient; }); - // Managed releases older than the pin, newest first. Newer folders belong to - // a newer server sharing this base dir and are never used or pruned here. + // Newer managed folders may belong to another server; never use or prune them. const olderManagedVersions = Effect.gen(function* () { const entries = yield* fileSystem .readDirectory(managedRoot) @@ -324,9 +319,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }); const resolvePathExecutable = Effect.gen(function* () { - // The default ConfigProvider is cached the first time any Config is read. - // Server startup reads its config before fixPath hydrates the environment, - // so reading PATH through Config here can retain the service's stale PATH. + // Read hydrated PATH directly; ConfigProvider may have cached it before fixPath. const pathValue = (yield* HostProcess.Environment).PATH?.trim(); if (!pathValue) return null; const delimiter = platform === "win32" ? ";" : ":"; @@ -375,11 +368,9 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }).pipe(Effect.withSpan("cloudflared.resolve")); const pruneManagedVersions: RelayClientShape["pruneManagedVersions"] = Effect.gen(function* () { - // The newest older release stays: an older server sharing this base dir, or a - // rollback after a failed update, may still need it and cannot redownload it. + // Retain the newest older release for rollback or older servers that cannot redownload it. for (const version of (yield* olderManagedVersions).slice(1)) { - // A connector from another server sharing this base dir may still run an - // older release; Windows refuses to delete it, so the next prune retries. + // Windows cannot delete another server's running connector; retry next prune. yield* fileSystem.remove(path.join(managedRoot, version), { recursive: true }).pipe( Effect.tap(() => Effect.logInfo("Removed an older managed relay client", { version })), Effect.catch((cause) => @@ -565,8 +556,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - // Requiring the pinned version here keeps a mislabelled asset from being - // activated, ignored by resolve, and downloaded again on every reconcile. + // Verify the pin so mislabeled assets cannot cause repeated redownloads. const installedVersion = yield* probeVersion(executablePath); if (installedVersion !== CLOUDFLARED_VERSION) { return yield* new RelayClientInstallError({ diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 66f7e79665d6b..3988e6a71abde 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -33,11 +33,7 @@ class LocalTracer extends Context.Reference("@t3tools/shared/relayTracing/LocalT defaultValue: () => Option.none(), }) {} -/** - * Exports every span `effect` creates through the product tracer. Use it only - * around T3 Connect work; wrap local work inside it with - * {@link withLocalTracing} so it stays off the product tracer. - */ +/** Export only T3 Connect spans; wrap local work with {@link withLocalTracing}. */ export const withRelayClientTracing = ( effect: Effect.Effect, ): Effect.Effect => @@ -58,10 +54,7 @@ export const withRelayClientTracing = ( ), ); -/** - * Its spans stay in the local trace and are never exported, so a relay span can - * time the work without shipping what the user's machine did to answer it. - */ +/** Keep local work in the local trace without exporting machine details through relay spans. */ export const withLocalTracing = (effect: Effect.Effect): Effect.Effect => LocalTracer.pipe( Effect.flatMap( diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 5a8ef72cba6b0..1942ced80c9f4 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -17,8 +17,7 @@ interface SchemaDiagnosticIssue { readonly path: ReadonlyArray; } -// Schema's default formatter includes actual values. These diagnostics cross -// process and UI boundaries, so retain only issue kinds and bounded paths. +// Default Schema diagnostics include values; expose only issue kinds and bounded paths across process/UI boundaries. function truncateDiagnostic(value: string, maxLength: number): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index 7bcda99bb3ddf..1b8d710fef734 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -84,10 +84,7 @@ function findBoundaryMatchIndex( return bestIndex; } -/** - * **Expects pre-normalized inputs**: both `value` and `query` must already be - * trimmed and lowercased (e.g. via {@link normalizeSearchQuery}). - */ +/** value and query must already be trimmed and lowercased. */ export function scoreQueryMatch(input: { value: string; query: string; diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index ee8a61e52d276..88ae4754f1809 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -135,10 +135,7 @@ export function compareSemverVersions(left: string, right: string): number { return 0; } -/** - * Keep the function body valid plain JavaScript: SSH startup stringifies this - * function and runs it on remote Node versions before TypeScript support is known. - */ +/** Keep plain JavaScript: SSH startup stringifies this for remote Node versions without known TS support. */ export const satisfiesSemverRange: (rawVersion: string, range: string) => boolean = function satisfiesSemverRange(rawVersion, range) { const normalizedVersion = String(rawVersion).trim().replace(/^v/, ""); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index ded5e845b08d0..ceb286c2e9f5a 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -110,13 +110,7 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ "devin", ].map((driver) => ProviderDriverKind.make(driver)); -/** - * `includeConfiguredInstances` is for the project-scoped caller only: a - * project that disabled every built-in instance should still be able to - * fall back to one it configured itself. The machine-level caller leaves it - * unset, since widening the candidates there would change behavior for - * every user regardless of project scope. - */ +/** Only project-scoped callers may include configured instances when falling back; machine-level callers leave it unset. */ export function fallbackTextGenerationModelSelection( providerInstances: ServerSettings["providerInstances"], isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, @@ -280,13 +274,7 @@ function mergeSettingsEntries( return Object.fromEntries(next); } -/** - * `projectSettingsOverrides` entries otherwise replace whole (see the field's - * doc comment in contracts), but `providerInstances` was added after clients - * already round-trip a project's entry on every edit. A client built before - * that addition sends an entry that never mentions the key, and a whole replace - * would silently wipe it. - */ +// Older clients omit providerInstances during whole-entry replacement; preserve it to avoid wiping overrides. function mergeProjectSettingsOverridesPatch( current: Readonly>, patch: Readonly>, @@ -353,8 +341,7 @@ function translateLegacyProjectOverridePatch( const entries = new Map( Object.entries(rest.projectSettingsOverrides ?? {}), ); - // A canonical entry in the same patch is the newer representation; a legacy - // map must not resurrect a key that entry deliberately omits. + // Canonical entries outrank legacy maps, including deliberately omitted keys. const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {})); const applyKey = ( map: Readonly> | undefined, diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index b7a656c885d73..690e6d94523c9 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -455,9 +455,7 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { PATHEXT: ".COM;.EXE", }); - // Windows 10+ can mark a directory case-sensitive, so the two spellings - // are not provably one directory and skipping the second could hide a - // command that is really there. + // Windows PATH directories can be case-sensitive; preserve both spellings. expect(probed).toHaveLength(8); }), ); @@ -556,8 +554,7 @@ describe("preferGitForWindowsBinary", () => { }); it("keeps the launcher when MSYSTEM is set", () => { - // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; - // without them a `#!/bin/sh` hook cannot start. + // git.exe adds hook dependencies to PATH only when MSYSTEM is unset. expect( preferGitForWindowsBinary( "C:\\Program Files\\Git\\cmd\\git.exe", @@ -625,8 +622,7 @@ describe("npmShimExecutable", () => { }); it("keeps a shim CMD would read differently", () => { - // CMD keeps trailing characters in an unquoted `SET` value, so these point - // `dp0`, and with it the executable, somewhere else. + // CMD preserves trailing SET whitespace, changing the executable path. for (const suffix of ["\u00a0", "\t", " "]) { const altered = NPM_OPENCODE_SHIM.replace("SET dp0=%~dp0", `SET dp0=%~dp0${suffix}`); expect( diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index df3d32747c2f2..73c54d1f8b88d 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -49,11 +49,7 @@ export type CommandAvailabilityChecker = ( const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; -/** - * Node hands the command string to `cmd.exe /d /s /c ""` without any - * quoting, so every dynamic argument must be escaped to survive both cmd.exe - * parsing and the target program's `CommandLineToArgvW` parsing. - */ +// Escape dynamic arguments for both cmd.exe and the target's CommandLineToArgvW parsing. function escapeWindowsShellArg(arg: string): string { let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); // Double up trailing backslashes so the closing quote is not escaped away. @@ -491,10 +487,7 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } -// Explicit-path resolution is never cached - callers probe paths they have -// just written (e.g. managed binary installs). A "not-found" outcome is also -// cached for the TTL, so a just-installed binary can stay invisible for up to -// 30s unless resolved by explicit path. +// Explicit paths bypass the cache for new installs; cached misses can otherwise hide binaries for 30 seconds. const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); @@ -524,8 +517,7 @@ const directoryMtime = (directory: string) => Effect.catch((error) => Effect.succeed(error.reason._tag === "NotFound" ? null : undefined)), ); -// Dated before it is read, so an entry added in between changes the mtime seen -// on the next check. Without names, lookups probe candidates directly. +// Timestamp before reading so concurrent additions change the next mtime check; missing listings fall back to direct probes. const listPathDirectory = Effect.fnUntraced(function* ( directory: string, ): Effect.fn.Return { @@ -681,8 +673,7 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function }); }); -// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds -// live in clangarm64 and 32-bit ones in mingw32. +// Git 2.56 x64 uses ucrt64; ARM64 uses clangarm64; older x64/32-bit builds use mingw64/mingw32. const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; function isFileSync(filePath: string): boolean { @@ -693,13 +684,7 @@ function isFileSync(filePath: string): boolean { } } -/** - * The launcher costs a second process on every git command, and each launch - * leaks a kernel token reference that slows process creation machine-wide until - * reboot. The real binary sets HOME itself, but adds its own folders to PATH - * for hooks, ssh and credential helpers only when MSYSTEM is unset, so the - * launcher stays when MSYSTEM is set. - */ +/** Bypass the launcher's extra process and kernel-token leak. Retain it when MSYSTEM is set, or git.exe omits hook/helper PATH entries. */ export function preferGitForWindowsBinary( executable: string, env: NodeJS.ProcessEnv, @@ -719,7 +704,7 @@ export function preferGitForWindowsBinary( return executable; } -// What npm's cmd-shim writes for a package whose bin is a native executable. +// npm cmd-shim format for native executables. const NPM_EXECUTABLE_SHIM_PREFIX = [ "@ECHO off", "GOTO start", @@ -740,12 +725,7 @@ function readFileSyncOrUndefined(filePath: string): string | undefined { } } -/** - * The executable an npm cmd-shim runs, when the shim does nothing else. - * Started through the shim, the executable is a child of `cmd.exe` and so - * escapes the kill-on-close job libuv puts every child in: it keeps running - * after this process is killed. Started directly it is in the job. - */ +/** Resolve shims that only launch native executables so direct spawning keeps them in libuv's kill-on-close job. */ export function npmShimExecutable( shimPath: string, readFile: (filePath: string) => string | undefined = readFileSyncOrUndefined, @@ -753,8 +733,7 @@ export function npmShimExecutable( ): string | undefined { const path = NodePath.win32; if (path.extname(shimPath).toLowerCase() !== ".cmd") return undefined; - // Compared exactly: whitespace CMD keeps, such as after `SET dp0=`, would - // make it run a different file than the one found here. + // Match exactly because preserved SET whitespace can select a different file. const lines = readFile(shimPath) ?.split(/\r?\n/) .filter((line) => line.length > 0); @@ -793,11 +772,8 @@ export const XcodeGitLocation = Context.Reference( const MACOS_GIT_SHIM = "/usr/bin/git"; /** - * /usr/bin/git is an xcrun shim that finds the active developer directory on - * every run: about 8 ms per spawn, against under 3 ms for the git behind it. - * Only the shim is replaced, so a git the user put first on PATH (Homebrew, - * Nix) still wins, and git's own children keep the same PATH. Any lookup - * failure keeps plain `git`. + * Skip the xcrun shim's per-spawn lookup only when git resolves to /usr/bin/git. + * User-selected git on PATH still wins, children keep PATH, and lookup failures use `git`. */ const resolveMacosGit = Effect.fnUntraced(function* ( env: NodeJS.ProcessEnv, @@ -834,7 +810,7 @@ const resolveMacosGit = Effect.fnUntraced(function* ( }); export interface SpawnCommandOptions extends CommandAvailabilityOptions { - /** Run the executable behind an npm cmd-shim directly (see `npmShimExecutable`). */ + /** Bypass npm cmd-shims; see npmShimExecutable for process-lifetime semantics. */ readonly bypassNpmShim?: boolean; } @@ -901,9 +877,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( return { command: shimExecutable, args: [...args], shell: false }; } - // Node runs a shell spawn as `cmd.exe /d /s /c " "`, but - // passing args with `shell: true` prints DEP0190. Build the same escaped line - // here and pass no args. + // Build the escaped command line without separate args to avoid Node DEP0190 with shell:true. return { command: [resolvedCommand, ...args].map(escapeWindowsShellArg).join(" "), args: [], diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 8ea093382a48c..646bf77ba4de5 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -22,6 +22,7 @@ export type T3McpToolSummaryAction = | "thread-wait" | "thread-interrupt" | "workflow-stop" + | "background-wait" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -194,6 +195,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Stop", "Stopping", "Requested a stop for", "a workflow"], "workflow-stop", ), + t3_background_wait: tool( + ["Update", "Updating", "Updated", "background waits"], + "background-wait", + ), t3_thread_interrupt: tool( ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt", @@ -401,13 +406,7 @@ function normalizeT3McpToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -/** - * ACP agents disagree on how the injected T3 server prefixes its tools: - * `mcp__t3-code__x` (Claude/Cursor), `t3-code.x` (Codex), plus single - * underscore, colon, slash, dash, and space separators seen from registry - * agents. The prefix match is deliberately loose because the display-name - * inventory is the real gate; unknown tools stay on the generic renderer. - */ +// ACP tool prefixes vary; match loosely but require a known display-name entry. function resolveT3McpToolName(value: string): string | null { const label = normalizeT3McpToolLabel(value); const mcpMatch = /^mcp__(?.+?)__(?.+)$/i.exec(label); @@ -430,9 +429,7 @@ function resolveT3McpToolName(value: string): string | null { const prefixed = /^(?:mcp[-_]{1,2})?t3[-_ ]?code(?:__|[-_.:/ ])(?.+)$/i.exec(label); const candidate = prefixed?.groups?.tool ?? label; if (Object.hasOwn(T3_MCP_TOOLS, candidate)) return candidate; - // OpenCode 2 registers one server per thread, `t3-code-`, and joins - // it to the tool with `_`. Thread ids can hold `_` too, so take the longest - // known tool name that ends the label. + // OpenCode 2 thread prefixes can contain underscores; choose the longest known tool-name suffix. if (!/^t3-code-/i.test(label)) return null; let longest: string | null = null; for (const tool of Object.keys(T3_MCP_TOOLS)) { diff --git a/packages/shared/src/terminalLabels.ts b/packages/shared/src/terminalLabels.ts index d020e46fade15..bd2e3ecce92b8 100644 --- a/packages/shared/src/terminalLabels.ts +++ b/packages/shared/src/terminalLabels.ts @@ -26,10 +26,7 @@ export function resolveTerminalSessionLabel( return getTerminalLabel(terminalId); } -/** - * When metadata is unavailable, callers append a UUID so an unseen host session - * cannot be reused accidentally. - */ +/** Append a UUID when metadata is unavailable to avoid reusing unseen host sessions. */ export function nextTerminalId( existingTerminalIds: ReadonlyArray, uniqueSuffix?: string, diff --git a/packages/shared/src/terminalOutput.test.ts b/packages/shared/src/terminalOutput.test.ts index ea2128ba8c34c..8870d422b763d 100644 --- a/packages/shared/src/terminalOutput.test.ts +++ b/packages/shared/src/terminalOutput.test.ts @@ -25,7 +25,6 @@ describe("appendTerminalOutput", () => { }); it("redraws several lines in place when the cursor moves up", () => { - // docker pull / pnpm style: print two bars, move up two lines, redraw both. const frames = [ "layer a: 10%\nlayer b: 10%\n", "\u001b[2A\u001b[2Klayer a: 60%\n\u001b[2Klayer b: 40%\n", @@ -115,7 +114,6 @@ describe("appendTerminalOutput under adversarial output", () => { describe("terminalOutputResumeText", () => { it("lets a viewer that starts from it apply later chunks exactly as the source does", () => { - // Mid-redraw: cursor two lines up, a colour on, and half an escape pending. const chunks = [ "a: 1%\nb: 1%\n", "\u001b[2A\u001b[2K\u001b[33ma: 5", diff --git a/packages/shared/src/terminalOutput.ts b/packages/shared/src/terminalOutput.ts index f4da028a3908a..9172554e07336 100644 --- a/packages/shared/src/terminalOutput.ts +++ b/packages/shared/src/terminalOutput.ts @@ -1,35 +1,15 @@ -/** - * Turns raw terminal bytes (already decoded to text) into a bounded tail that - * a timeline can show. Runs on the server before output crosses the wire, and - * again on clients when they splice appends onto what they hold, so both sides - * must reach the same state from the same input. - * - * It models a small terminal: the last `LIVE_LINES` lines form a window that - * cursor movement can rewrite, so `\r` progress bars and multi-line redraws - * (docker pull, pnpm, test reporters) keep only their latest frame. Older lines - * are fixed text. The model has no width, so lines never wrap. - * - * - Colours and text attributes (SGR) survive as canonical `ESC[0;…m` codes. - * Every line starts from the default style and resets at its end, so any line - * boundary is a safe place to cut. `terminalOutputSpans` reads them back. - * - Cursor moves, line and screen erases, and save/restore are applied. Other - * escape sequences (titles, modes) and control characters are dropped. - * - Only the last `maxChars` survive, cut at a line boundary when possible. - * - * Chunks can split an escape sequence, so the unfinished fragment is carried in - * `pending` until the next chunk arrives. - */ +/** Server/client folds must agree: only the last LIVE_LINES are cursor-addressable, with no wrapping. + * SGR resets at line boundaries for safe tail cuts; unfinished escapes carry through pending. */ export interface TerminalOutputState { - /** What to show: fixed lines, then the window. Contains only SGR escapes. */ + /** Fixed lines followed by the live window; contains only SGR escapes. */ readonly text: string; - /** True once anything was dropped from the front. */ readonly truncated: boolean; /** An unfinished escape sequence awaiting the next chunk. */ readonly pending: string; readonly screen: TerminalScreen; } -/** A styled stretch of one line. `pen` is canonical SGR parameters, "" for default. */ +/** Canonical SGR parameters; empty means default style. */ interface Run { readonly pen: string; readonly text: string; @@ -37,9 +17,9 @@ interface Run { type Line = ReadonlyArray; interface TerminalScreen { - /** Serialized lines, each ending in `\n`, that scrolled out of the window. */ + /** Scrolled-out lines, each ending in newline. */ readonly settled: string; - /** The live window; never empty. */ + /** Never empty. */ readonly lines: ReadonlyArray; readonly row: number; readonly col: number; @@ -54,13 +34,11 @@ export const EMPTY_TERMINAL_OUTPUT: TerminalOutputState = { screen: { settled: "", lines: [[]], row: 0, col: 0, pen: "", saved: null }, }; -/** The default tail kept for one running command, in UTF-16 code units. */ +/** Default command tail, in UTF-16 code units. */ export const TERMINAL_OUTPUT_TAIL_CHARS = 64 * 1024; -/** Lines that cursor movement can still reach; older output is fixed. */ const LIVE_LINES = 64; -// Longest fragment we will hold waiting for an escape sequence to finish. const MAX_PENDING_ESCAPE = 64; const ESC = "\u001b"; @@ -69,7 +47,7 @@ function sgr(pen: string): string { return pen === "" ? `${ESC}[0m` : `${ESC}[0;${pen}m`; } -// Lengths of lines already measured. Lines are never mutated once a feed ends. +// Lines stay immutable after a feed, so their measured lengths can be cached. const lineLengths = new WeakMap(); function lineLength(line: Line): number { @@ -81,11 +59,9 @@ function lineLength(line: Line): number { return length; } -// Past this many style changes, a line's oldest runs lose their style. Rendering -// already caps spans, and it keeps per-write work bounded on colour-per-character output. +// Strip oldest styles to bound work on color-per-character output. const MAX_LINE_RUNS = 2_048; -/** Runs covering columns [start, end) of `line`. */ function sliceLine(line: Line, start: number, end: number): Array { const out: Array = []; let offset = 0; @@ -113,7 +89,7 @@ function pushRun(runs: Array, run: Run): void { } } -/** Drops the first `count` columns of a line this feed owns, in place. */ +/** Mutates only lines owned by this feed. */ function trimLineStart(runs: Array, count: number): void { let dropped = 0; let remaining = count; @@ -130,11 +106,9 @@ function trimLineStart(runs: Array, count: number): void { if (dropped > 0) runs.splice(0, dropped); } -/** `line` with `text` written over it from `col`, as a terminal overwrites. */ function writeLine(line: Line, col: number, text: string, pen: string): Line { const length = lineLength(line); if (col === length) { - // The common case: printing at the end of the line. const runs = line.slice(); pushRun(runs, { pen, text }); return runs; @@ -212,7 +186,7 @@ function formatPen(attributes: Attributes): string { return parts.join(";"); } -/** Reads an extended colour (`5;n` or `2;r;g;b`) starting at `index`; returns the code and next index. */ +/** Reads 5;n or 2;r;g;b colors; returns the code and next index. */ function extendedColor( code: number, params: ReadonlyArray, @@ -272,7 +246,6 @@ function escapeLength(input: string, index: number): number { const next = input[index + 1]; if (next === undefined) return -1; if (next === "[") { - // CSI: parameters/intermediates, then a final byte in @-~. for (let cursor = index + 2; cursor < input.length; cursor += 1) { const code = input.charCodeAt(cursor); if (code >= 0x40 && code <= 0x7e) return cursor - index + 1; @@ -280,8 +253,7 @@ function escapeLength(input: string, index: number): number { return -1; } if (STRING_SEQUENCE_INTRODUCERS.has(next)) { - // OSC/DCS/SOS/APC/PM end at BEL or ST (ESC \). A newline also ends one (and is - // kept), so a stray introducer in binary output can't hide everything after it. + // BEL/ST ends string sequences; also stop at newlines so stray binary introducers cannot hide later output. for (let cursor = index + 2; cursor < input.length; cursor += 1) { if (input[cursor] === "\u0007") return cursor - index + 1; if (input[cursor] === "\n") return cursor - index; @@ -292,7 +264,7 @@ function escapeLength(input: string, index: number): number { } return -1; } - // Charset designation (ESC ( B) takes three; other two-byte sequences (ESC 7) two. + // Charset designations take three bytes; other sequences take two. if (next === "(" || next === ")" || next === "*" || next === "+") { return index + 2 < input.length ? 3 : -1; } @@ -301,8 +273,7 @@ function escapeLength(input: string, index: number): number { const penTransitions = new Map(); -/** CSI parameters as numbers. Colon sub-parameters (`38:2::r:g:b`) carry an optional - colour-space id that the semicolon form (`38;2;r;g;b`) has no slot for. */ +// Colon color sub-parameters carry an optional color-space id absent from the semicolon form. function csiParams(params: string): Array { const numbers: Array = []; for (const part of params.split(";")) { @@ -317,7 +288,6 @@ function csiParams(params: string): Array { return numbers; } -/** A mutable copy of the screen, for applying one chunk. */ class Terminal { settled: string; lines: Array; @@ -345,7 +315,6 @@ class Terminal { let line = this.lines[this.row]!; const length = lineLength(line); if (this.col === length && this.owned.has(line)) { - // Printing at the end of a line this feed already copied: extend it in place. pushRun(line as Array, { pen: this.pen, text }); lineLengths.set(line, length + text.length); } else { @@ -354,7 +323,7 @@ class Terminal { } this.col += text.length; if (line.length > MAX_LINE_RUNS) { - // Fold all but the newest runs into one plain run, so trimming below eats it cheaply. + // Flatten old runs so later trimming stays cheap. const keep = line.length - MAX_LINE_RUNS / 8; const plain = line .slice(0, keep) @@ -363,8 +332,7 @@ class Terminal { line = [{ pen: "", text: plain }, ...line.slice(keep)]; this.owned.add(line); } - // A line keeps only its last `maxChars` columns, trimmed on every write so the - // result never depends on how the output was split into chunks. + // Trim each write to maxChars columns so results do not depend on chunk boundaries. let current = lineLength(line); if (current > this.maxChars) { let removed = current - this.maxChars; @@ -397,7 +365,6 @@ class Terminal { while (this.lines.length > LIVE_LINES) this.scrollOut(); } - /** Moves the top line out of the window into scrollback; returns its length. */ scrollOut(): number { const line = this.lines.shift()!; this.settle(line); @@ -406,7 +373,6 @@ class Terminal { return lineLength(line); } - /** Fixes a line that left the window, keeping scrollback near the tail size as it grows. */ settle(line: Line): void { this.settled += `${serializeLine(line)}\n`; if (this.settled.length > 2 * this.maxChars) { @@ -427,7 +393,6 @@ class Terminal { const count = Math.max(1, numbers[0] || 1); switch (final) { case "m": { - // Colourful output repeats a handful of transitions, so they are cached. const key = `${this.pen}|${params}`; let pen = penTransitions.get(key); if (pen === undefined) { @@ -470,11 +435,9 @@ class Terminal { for (let row = 0; row < this.row; row += 1) this.lines[row] = []; this.lines[this.row] = eraseLine(this.lines[this.row]!, this.col, 1); } else if (mode === 3) { - // Clear scrollback: what already scrolled out goes, the window stays. if (this.settled !== "") this.dropped = true; this.settled = ""; } else { - // A cleared screen starts the window over; what scrolled out stays. this.lines = [[]]; this.row = 0; this.col = 0; @@ -499,7 +462,7 @@ class Terminal { } else if (sequence === `${ESC}8`) { if (this.saved !== null) this.moveTo(this.saved.row, this.saved.col); } else if (sequence === `${ESC}M`) { - this.moveTo(this.row - 1, this.col); // Reverse line feed. + this.moveTo(this.row - 1, this.col); } } @@ -511,7 +474,6 @@ class Terminal { }; for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); - // Printable text, including tabs, is batched into one write. if (code === 0x09 || (code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f))) { continue; } @@ -525,19 +487,16 @@ class Terminal { if (length === -1) { const fragment = input.slice(index); if (fragment.length <= MAX_PENDING_ESCAPE) return fragment; - // A long string sequence keeps only its introducer (and a trailing ESC that - // may start its ST), so the payload stays hidden until it ends. + // Retain the introducer and possible ST prefix so long string payloads stay hidden until termination. if (STRING_SEQUENCE_INTRODUCERS.has(fragment[1]!)) { return fragment.slice(0, 2) + (fragment.endsWith(ESC) ? ESC : ""); } - // Any other runaway sequence is noise; drop it instead of waiting forever. return ""; } this.escape(input.slice(index, index + length)); index += length - 1; printable = index + 1; } - // Other C0, DEL and C1 controls carry no visible text. } flush(input.length); return ""; @@ -582,7 +541,6 @@ export function terminalOutputTail( return { text: style.prefix + text.slice(start), truncated: true }; } -/** Appends one decoded chunk and returns the new bounded state. */ export function appendTerminalOutput( state: TerminalOutputState, chunk: string, @@ -593,7 +551,6 @@ export function appendTerminalOutput( const terminal = new Terminal(state.screen, maxChars); const pending = terminal.feed(input); let truncated = state.truncated; - // The window as a whole stays within the tail too: oldest lines scroll out early. let windowChars = terminal.lines.reduce((total, line) => total + lineLength(line), 0); while (windowChars > maxChars && terminal.row > 0) windowChars -= terminal.scrollOut(); // Lines below a cursor that moved up can't scroll out; they keep only their ends. @@ -636,11 +593,7 @@ export function appendTerminalOutput( }; } -/** - * Text that rebuilds `state` when fed to an empty state: the output, then the - * cursor, style and any unfinished escape. A viewer that starts from it (or - * resyncs to it) applies later raw chunks exactly as the server does. - */ +/** Rebuilds state from empty, including cursor/style/pending escapes, so later raw chunks fold identically. */ export function terminalOutputResumeText(state: TerminalOutputState): string { const { screen } = state; const lastRow = screen.lines.length - 1; @@ -666,7 +619,6 @@ export function terminalOutputResumeText(state: TerminalOutputState): string { return state.text + suffix + state.pending; } -/** Normalizes a complete output string in one pass. */ export function normalizeTerminalOutput( text: string, maxChars: number = TERMINAL_OUTPUT_TAIL_CHARS, @@ -675,13 +627,11 @@ export function normalizeTerminalOutput( return { text: state.text, truncated: state.truncated }; } -/** A stretch of shown output with one style. */ export interface TerminalSpan { readonly text: string; readonly style: TerminalSpanStyle | null; } -/** A span's style, usable as-is by a web or React Native `style` prop. */ export interface TerminalSpanStyle { /** CSS colours, already resolved against the palette. */ readonly color?: string; @@ -692,7 +642,6 @@ export interface TerminalSpanStyle { readonly textDecorationLine?: "underline" | "line-through" | "underline line-through"; } -/** The 16 standard terminal colours, chosen to stay readable on each app theme. */ export const TERMINAL_PALETTES = { dark: [ "#3b4048", @@ -761,7 +710,6 @@ function resolveColor( type Rgb = readonly [number, number, number]; -/** Reads the `#rrggbb` and `rgb(r,g,b)` colours this module produces. */ function parseCssColor(color: string): Rgb | undefined { if (color.startsWith("#") && color.length === 7) { return [1, 3, 5].map((offset) => @@ -821,11 +769,7 @@ function spanStyle(pen: string, palette: ReadonlyArray): TerminalSpanSty }; } -/** - * Splits shown output into styled spans for rendering. Past `maxSpans` the rest - * is one plain span, so a pathological stream of colour changes cannot turn - * into tens of thousands of DOM or native nodes. - */ +/** Caps styled spans at maxSpans, then emits one plain remainder to bound DOM/native nodes. */ export function terminalOutputSpans( text: string, palette: ReadonlyArray, @@ -866,7 +810,6 @@ export function terminalOutputSpans( const SGR_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, "gu"); -/** Shown output without its colour codes. */ export function terminalOutputPlainText(text: string): string { return text.replace(SGR_PATTERN, ""); } diff --git a/packages/shared/src/testing/longTempDir.ts b/packages/shared/src/testing/longTempDir.ts index e542695fd7ded..09a4da9f8f53b 100644 --- a/packages/shared/src/testing/longTempDir.ts +++ b/packages/shared/src/testing/longTempDir.ts @@ -3,10 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as HostProcess from "../HostProcess.ts"; -// GitHub's Windows runners hand out the temp directory by its 8.3 short name -// (C:\Users\RUNNER~1\...). Anything that canonicalises a path, such as git or -// realpath, reports the long form, so equality checks between a temp path and -// its canonical form fail. +// Windows runner temp paths use 8.3 names; canonicalize to long names before equality checks. if (HostProcess.Platform.defaultValue() === "win32") { try { const longForm = NodeFS.realpathSync.native(NodeOS.tmpdir()); diff --git a/packages/shared/src/testing/symlinks.ts b/packages/shared/src/testing/symlinks.ts index 00091311f75a0..e7b1aeb54edab 100644 --- a/packages/shared/src/testing/symlinks.ts +++ b/packages/shared/src/testing/symlinks.ts @@ -3,11 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; -/** - * Windows refuses without Developer Mode or elevation, so tests that plant a - * symlink to prove containment or escape detection should - * `skipIf(!symlinksSupported)` rather than fail at setup. - */ +/** Skip symlink fixtures when unsupported; Windows requires Developer Mode or elevation. */ export const symlinksSupported: boolean = (() => { let directory: string | undefined; try { @@ -17,8 +13,7 @@ export const symlinksSupported: boolean = (() => { } catch { return false; } finally { - // Cleanup must not decide the answer: a transient EPERM/EBUSY on the - // probe directory would otherwise throw out of module initialisation. + // Cleanup EPERM/EBUSY must not fail module initialization or decide symlink support. try { if (directory !== undefined) NodeFS.rmSync(directory, { recursive: true, force: true }); } catch {} diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index 70febb526d437..d55b559b23eea 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -4,12 +4,7 @@ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; export const MOBILE_THEME_IDS = [MOBILE_DEFAULT_THEME_ID, ...BUILT_IN_THEME_IDS] as const; -/** - * Ids a theme may not take: the appearance keywords a stored preference uses, - * every built-in, and the legacy aliases older saves still carry. Taking one - * would either be shadowed by the built-in or capture clients that never chose - * it, so the client library and the publish path both consult this set. - */ +/** Reserve appearance keywords, built-ins, and legacy aliases so custom themes cannot capture stored preferences. */ export const RESERVED_THEME_IDS: ReadonlySet = new Set([ "system", "light", @@ -22,12 +17,7 @@ export const RESERVED_THEME_IDS: ReadonlySet = new Set([ "t3-iris", ]); -/** - * Additionally closed to a machine publishing a theme: the mobile default is - * not a web or desktop built-in, so a saved theme may legitimately carry that - * id, but no client that follows published themes can resolve it -- publishing - * it would report success and change nothing. - */ +/** The mobile default id is valid in saved themes but cannot be published because web/desktop cannot resolve it. */ export const UNPUBLISHABLE_THEME_IDS: ReadonlySet = new Set([ ...RESERVED_THEME_IDS, MOBILE_DEFAULT_THEME_ID, diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts index ef27422dedf86..a6c8eebc7e65d 100644 --- a/packages/shared/src/threadLinks.test.ts +++ b/packages/shared/src/threadLinks.test.ts @@ -98,6 +98,14 @@ describe("thread links", () => { ); }); + it("writes the thread id verbatim so the link opens that exact thread", () => { + const link = formatThreadLink("thread:delegated-task:mcp%3A1", "Fix"); + expect(link).toBe("[Fix](t3-thread://v1/thread:delegated-task:mcp%3A1)"); + expect(parseThreadLinkHref(link.slice(link.indexOf("](") + 2, -1))).toBe( + "thread:delegated-task:mcp%3A1", + ); + }); + it("formats a label that would otherwise break the Markdown link", () => { expect(formatThreadLink("t1", "Fix [ci] \\ build")).toBe("[Fix ci build](t3-thread://v1/t1)"); expect(formatThreadLink("t1", " ] ")).toBe("[t1](t3-thread://v1/t1)"); diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index c86fcc1158bfd..0ff12bc3c3f1b 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -1,7 +1,6 @@ import type { ThreadLinkedPullRequest, ThreadPullRequestLink } from "@t3tools/contracts"; -/** Tombstoned stack members stay in the array only so the - * sync reactor does not re-add them. */ +/** Tombstones prevent sync from re-adding hidden members. */ export function visibleThreadPullRequests( links: ReadonlyArray, ): ReadonlyArray { diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 1b54c695b09f5..258938edf7b05 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -194,10 +194,7 @@ function extractPrimaryPath(data: Record | undefined): string | return collectToolFilePaths(data)[0]; } -/** - * Later ACP updates often resend `rawInput: {}`. Keep the first object that - * actually carried keys so a parsed path is not wiped out. - */ +/** Preserve the first nonempty rawInput; later ACP updates often resend an empty object. */ export function mergeToolActivityData( previous: unknown, next: unknown, diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index be788033d0fbe..2a80641d13b5e 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -155,8 +155,7 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un } if (complete) output.threads = threads; else { - // A partial batch or a top-level ID would turn an unknown creation - // count into a confidently wrong one in the existing summary parser. + // Partial batches or top-level ids make the summary parser report a false creation count. delete output.threadId; delete output.status; } @@ -186,13 +185,7 @@ export function htmlRenderFromToolItem(item: { return output?.isError ? undefined : output?.htmlRender; } -/** - * The MCP App a completed tool call carries, if any. The adapter that captured - * it put the reference in the output, beside the tool's own result. A tool's - * result can imitate that shape, so the reference only counts when it names - * the very server and tool the item records: a server can then only ever - * point at an app of its own. - */ +/** Accept app references only when server and tool match the item, so tools cannot impersonate another app source. */ export function mcpAppFromToolItem(item: { readonly toolName: string | null | undefined; readonly output?: unknown; @@ -220,10 +213,7 @@ export interface ToolOutputImage { readonly data?: string; } -/** - * Only raster types are recognized, so the server never serves an agent's SVG - * or HTML inline. - */ +/** Raster types only, so agent SVG/HTML cannot be served inline. */ export function readToolOutputImage(block: unknown): ToolOutputImage | null { if (!Predicate.isObject(block) || block.type !== "image") return null; const source = Predicate.isObject(block.source) ? block.source : undefined; @@ -258,10 +248,7 @@ export function toolOutputImageBlocks(value: unknown): ReadonlyArray { return blocks; } -/** - * The order is the `tool-output-image` asset index, so servers and clients - * agree on it. - */ +/** Order defines the shared tool-output-image asset index. */ export function toolOutputImages(value: unknown): ReadonlyArray { return toolOutputImageBlocks(value).flatMap((block) => readToolOutputImage(block) ?? []); } diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bcdc0edb950b6..8c9792353af55 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -239,9 +239,7 @@ export function makeWindow( untilTime: untilTime.toISOString(), }; } - // Subtracting fixed milliseconds from `now` lands on the wrong calendar day - // around a DST transition. The window start is pure calendar arithmetic on - // the local end day, done in UTC where days are uniform. + // Use calendar arithmetic in UTC; fixed millisecond subtraction crosses DST days incorrectly. const [year = 0, month = 1, dayOfMonth = 1] = untilDay .split("-") .map((part) => Number.parseInt(part, 10)); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 4b45c904bcb3b..0a21aa52eaa31 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -130,10 +130,7 @@ const nativeReport = (provider: ServerProvider): AccountReport => ({ usageLimits: provider.usageLimits, }); -/** - * Pass every enabled login, read or not, so an unreadable workspace still - * counts as one. - */ +// Include every enabled login, even unreadable workspaces. function accountKeyResolver(reports: Iterable) { const workspaces = new Map>(); for (const report of reports) { @@ -176,11 +173,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl const creditSources = new Map(); const hubRedeems = new Map(); const merge = (key: string, next: LimitAccount) => { - // Redeeming through a hub also clears the routing cooldown that hub holds - // for the account. Redeeming natively against the same subscription resets - // it upstream but leaves the hub refusing to route to the account until - // its own cooldown expires, so a hub target wins the redemption outright - // while the displayed balance still follows the freshest read. + // Prefer hub redemption to clear its routing cooldown too; displayed balance still follows the freshest read. const previousHub = hubRedeems.get(key); if ( next.redeem && @@ -409,11 +402,7 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } -/** - * Codex ids are slots, not allowances: a weekly-only plan reports its weekly - * window as `primary`, other plans as `secondary`. Its windows pool by kind; - * other drivers' ids name distinct allowances. - */ +// Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances. function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; } @@ -482,9 +471,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L const first = members[0]!.window; const id = members[0]!.account.driver === "codex" ? key : first.id; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; - // Pace compares spend against the clock, so it is judged only over the - // members that have a clock; a window with no reset would otherwise - // count as spend with no time elapsed and skew the verdict. + // Judge pace only for windows with reset clocks; others add spend without elapsed time. const timed = members.flatMap((m) => { const share = elapsedShare(m.window, now); return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index dec17b6e976d1..1233280e828c0 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -39,10 +39,7 @@ export interface ModelTotals { readonly tokenShare: number; } -/** - * A model whose every record lacked rates has an unknown cost, not a zero one. - * Clients must not present its `costUsd` as a real dollar figure. - */ +/** When every record lacks rates, costUsd is unknown and must not be displayed as a dollar figure. */ export function isModelCostUnknown(model: ModelTotals): boolean { return model.records > 0 && model.unpricedRecords >= model.records; } @@ -114,10 +111,7 @@ export interface MergedUsage { readonly accountUsage: UsageAccountConsumption | null; } -/** - * `volumeId` is what stops two machines that happen to share a hostname and a home path, which is - * every Mac in a fleet, from collapsing into one source and having one of them silently dropped. - */ +// volumeId distinguishes machines sharing a hostname and home path. function fingerprintKey(fingerprint: UsageSourceFingerprint): string { return [ fingerprint.hostId, @@ -181,9 +175,7 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } } - // A newer partial scan may contain usage recorded after an older complete - // scan. Keep cells absent from the complete scan. Aggregated cells do not - // reveal enough to reconcile overlapping records without double counting. + // Keep cells unique to newer partial scans; aggregates cannot reconcile overlapping records without double-counting. for (const environment of ordered) { for (const source of environment.summary.sources) { if (source.status !== "partial") continue; @@ -246,8 +238,7 @@ function ownedContribution( const provider = source.fingerprint.provider; ownedProviders.add(provider); ownedSources.add(`${provider}\u0000${source.fingerprint.resolvedHomePath}`); - // Distinct within a directory. Summing per-bucket session counts instead - // would count a session once per day and model it spans. + // Count distinct sessions per directory; bucket sums overcount sessions spanning days/models. sessionsByProvider.set( provider, (sessionsByProvider.get(provider) ?? 0) + diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index b12901d6acebe..73006ac3053f5 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -362,7 +362,7 @@ const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => : null; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; @@ -492,10 +492,7 @@ export const make = Effect.gen(function* () { ), createRepository: (input) => { const repository = parseRepositorySpecifier(input.repository); - // Azure Repos access is governed by project/organization permissions. - // `az repos create` does not expose a per-repository visibility flag, so - // the generic source-control visibility input is intentionally not - // translated into CLI args for this provider. + // Project/organization permissions control visibility; `az repos create` has no flag. return executeJson({ cwd: input.cwd, args: [ diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index cb14afba2bb26..30874e96fcec3 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -104,31 +104,17 @@ export type AzureDevOpsPullRequestCliError = const REST_API_VERSION = "7.1"; const PULL_REQUEST_LIST_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -/** - * A full page of change entries is two thousand files, each carrying its path, its url and - * several object ids, which is past the megabyte a read is given by default. - */ +/** A 2,000-file page exceeds the default 1 MiB output budget. */ const CHANGE_ENTRIES_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -/** - * Four times the megabyte of file the other hosts hand over, because Azure has no route that - * serves the bytes themselves: the file arrives inside a JSON envelope, escaped if it is text and - * base64 if it is not, and both are larger than the file they carry. - */ +/** JSON escaping and base64 can exceed the decoded file's size. */ const ITEM_CONTENT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; -/** - * Neither route pages, so each answers with the whole of it at once and grows with how - * long the review ran rather than with how large the change is. - */ +/** Review history is unpaged and independent of diff size. */ const REVIEW_HISTORY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; -/** Azure's own ceiling for one page of an iteration's changes. */ +/** Azure's iteration-change page limit. */ const CHANGE_ENTRIES_PER_PAGE = 2000; -/** - * Where following the pages stops, counted in the entries Azure was asked to skip rather than in - * the files that survived decoding: a page can be entirely folders and other entries a review has - * nothing to show for, and bounding on what was kept would follow such a change forever. - */ +/** Count raw entries, including folders and malformed rows. */ const MAX_CHANGE_ENTRIES = 10_000; export interface AzureDevOpsIterationChanges { @@ -171,27 +157,19 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; - /** Threads are not reachable through `az repos pr`, so they come from the REST API. */ readonly listThreads: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; readonly number: number; }) => Effect.Effect, AzureDevOpsPullRequestCliError>; - /** - * Azure hangs the changed files off an iteration rather than off the pull - * request, so reading a diff starts here. - */ readonly listIterations: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; readonly number: number; }) => Effect.Effect, AzureDevOpsPullRequestCliError>; - /** - * What one iteration changed, against the merge base rather than against the previous push, - * which is the whole of the pull request rather than the latest slice of it. - */ + /** Changes cover the whole PR from its merge base. */ readonly listIterationChanges: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -199,10 +177,6 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly iterationId: number; }) => Effect.Effect; - /** - * One file's text at one commit. Azure has no diff route that carries content, so both sides - * of every changed file are read this way and the patch is made from them here. - */ readonly readItemContent: (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -224,10 +198,6 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly body?: string | undefined; }) => Effect.Effect; - /** - * `az repos pr reviewer` is the whole of what Azure offers here: it adds and removes named - * identities, and has no counterpart that says who could be named. - */ readonly setPullRequestReviewers: (input: { readonly cwd: string; readonly number: number; @@ -298,20 +268,16 @@ function actionArgs( } } -/** - * The dash is the whole point — these are argv, and a value that looks like a - * flag stops being a value. - */ function isReviewerName(value: string): boolean { const name = value.trim(); return name.length > 0 && !name.startsWith("-"); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; - // The remote takes three shapes and only `az` knows how to read all of them. + // Let `az` parse all three remote formats. const detectArgs = ["--detect", "true"] as const; const executeJson = (input: { @@ -325,12 +291,7 @@ export const make = Effect.gen(function* () { ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), }); - /** - * Used in place of `az rest` because it signs in the way the azure-devops extension does: `az - * rest` mints its own token against the tenant `az` defaults to, and an organisation in any - * other tenant answers that with a sign-in page, which arrives here as unreadable output - * rather than as a failure. - */ + /** Use the extension's credentials; `az rest` uses the default tenant. */ const invoke = (input: { readonly cwd: string; readonly operation: string; @@ -375,11 +336,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Azure names its own items with a leading slash, which the repository paths carried around - * here have had taken off so they match the patch and the viewed mark. Put it back on the way - * out, because the items route is documented in Azure's own spelling. - */ + /** Azure's items route requires a leading slash. */ const toItemPath = (path: string) => (path.startsWith("/") ? path : `/${path}`); const repositoryRoute = (location: AzureDevOpsRepositoryLocation): ReadonlyArray => [ @@ -395,10 +352,7 @@ export const make = Effect.gen(function* () { `pullRequestId=${input.number}`, ]; - /** - * Azure pages by raw offset. Keep reading when malformed rows leave the decoded page short, and - * retain the raw count so the next public cursor skips every row this walk consumed. - */ + /** Decoding can shorten a non-final page; advance by raw rows. */ const listPullRequestPage = (input: { readonly cwd: string; readonly repository: string; @@ -509,9 +463,7 @@ export const make = Effect.gen(function* () { ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) : Effect.succeed(decoded.success); }), - // `az devops login` signs in with a personal access token, which `az account show` knows - // nothing about, and nothing in `az` prints whose token it is. `--creator me` is resolved - // by the extension itself, so the viewer's own pull request names them. + // `az account show` cannot identify PAT logins; the extension resolves `--creator me`. Effect.catchIf( (error) => error._tag !== "AzureDevOpsCliUnavailableError", () => @@ -648,12 +600,9 @@ export const make = Effect.gen(function* () { "versionDescriptor.versionType=commit", `versionDescriptor.version=${input.commit}`, "includeContent=true", - // Azure leaves `contentMetadata` out unless this is asked for, and with it goes its own - // word on whether the file is binary, which is the only reliable one, since a binary - // file arrives encoded rather than as the bytes it is on the host. + // Encoded content cannot identify binaries; request Azure's binary flag. "includeContentMetadata=true", - // Without this Azure answers with the file's own bytes rather than with a JSON - // envelope, and `az devops invoke` refuses anything it cannot parse as JSON. + // `az devops invoke` requires JSON; otherwise Azure returns raw file bytes. "$format=json", ], maxOutputBytes: ITEM_CONTENT_MAX_OUTPUT_BYTES, @@ -674,8 +623,7 @@ export const make = Effect.gen(function* () { ...detectArgs, "--id", String(input.number), - // One `--reviewers` takes them all, because az reads the flag as a list and a - // second one would replace the first rather than add to it. + // Repeating `--reviewers` replaces the earlier list. "--reviewers", ...input.reviewers, "--only-show-errors", @@ -715,9 +663,7 @@ export const make = Effect.gen(function* () { ...detectArgs, "--id", String(input.number), - // One argument rather than a flag and a value beside it: a description usually opens - // with a bullet, and az reads a dash in the next argv slot as a flag of its own. - // `--description` also takes several strings, and this keeps the whole text as one. + // One argv entry preserves leading dashes and multi-string descriptions. ...(input.title === undefined ? [] : [`--title=${input.title}`]), ...(input.body === undefined ? [] : [`--description=${input.body}`]), "--only-show-errors", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts index 4c8d2f74edefb..345d74de84ba1 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.test.ts @@ -83,8 +83,7 @@ const readSlice = (input: { reads.push(item.path); inFlight += 1; peakInFlight = Math.max(peakInFlight, inFlight); - // Every read suspends before it answers, as a subprocess would, so what runs at - // once is the scheduler's answer rather than an artefact of resolving inline. + // Yield like a subprocess to exercise concurrency. const answersAfter = input.paths.length - input.paths.indexOf(item.path); for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow; inFlight -= 1; @@ -208,8 +207,7 @@ describe("getDiff reads", () => { Effect.gen(function* () { inFlight += 1; peakInFlight = Math.max(peakInFlight, inFlight); - // Suspends before answering, as a subprocess would, so what is out at once is the - // scheduler's answer rather than an artefact of resolving inline. + // Yield like a subprocess to exercise concurrency. yield* Effect.yieldNow; yield* Effect.yieldNow; inFlight -= 1; diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts index 1e901f8f1f98b..615adfded8f18 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestProvider.ts @@ -35,17 +35,10 @@ import type { } from "./azureDevOpsPullRequestJson.ts"; import { definition } from "../shared/definition.ts"; -/** - * Every file is two `az` invocations, each paying a Python interpreter's start-up, so reading them - * one after another is most of what the Code tab waits for. - */ +/** Each file pays for two Python startups. */ const DIFF_FILE_CONCURRENCY = 4; -/** - * How many `az` processes this build will have out at once, counted across every reader rather than - * per request. Held at what one request at full width spends, so a second reader waits behind the - * first instead of adding to it. - */ +/** Readers share a spawn budget across requests. */ export const MAX_DIFF_SPAWNS = 2 * DIFF_FILE_CONCURRENCY; export const LOCATION_CACHE_CAPACITY = 128; @@ -54,19 +47,16 @@ const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: false, actions: [...definition.changeRequestActions], - // Azure squashes as a completion option; it has no rebase strategy of its own. + // Azure has no rebase strategy. mergeMethods: ["merge", "squash"], - // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. + // `az repos pr list` has no text filter. search: false, reactions: false, review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, - // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` - // lists the ones this repository could name — that lives behind the identity and graph APIs, a - // different service with its own permissions. + // Reviewer candidates require separate identity/graph APIs and permissions. reviewers: { request: true, listCandidates: false }, edit: { changeRequest: true, comment: false }, - // Azure does keep a viewed record of its own, but only behind the undocumented contribution - // endpoint its web UI talks to, keyed on an iteration so a push would drop every mark anyway. + // Azure's viewed marks use an undocumented endpoint and are invalidated by each push. viewedFiles: "environment", }; @@ -110,8 +100,7 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq state: pullRequest.state, isDraft: pullRequest.isDraft, mergeability: pullRequest.mergeability, - // Azure counts a pull request's files but never its lines, and counting them here would mean - // reading every file on both sides of every row of a listing. + // Azure omits line counts; computing them requires both sides of every file. additions: 0, deletions: 0, createdAt: pullRequest.createdAt, @@ -152,12 +141,7 @@ export const make = Effect.gen(function* () { const EMPTY_DIFF_SLICE: ProviderDiffSlice = { patch: "", truncated: false, nextCursor: null }; - /** - * A pull request cannot move between repositories, so it is remembered rather than re-read: the - * marks alone would otherwise pay for a whole pull request read every time they checked whether a - * file had been pushed to. Least recently used, so a listing walking cold pull requests cannot - * evict the one being read. - */ + // PR locations are immutable; caching avoids full PR reads for viewed-file checks. const locations = new Map(); const locationOf = (input: { readonly cwd: string; readonly number: number }) => { @@ -198,10 +182,7 @@ export const make = Effect.gen(function* () { const EMPTY_ITEM: AzureDevOpsItemContent = { contents: "", isBinary: false }; - /** - * Only the sides a change actually has are asked for: Azure answers for a file that is not at a - * commit with a failure rather than with nothing. - */ + /** Azure fails reads of absent files. */ const readTexts = (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -232,16 +213,11 @@ export const make = Effect.gen(function* () { Effect.map(([oldItem, newItem]): AzureDevOpsFileTexts => ({ oldContents: oldItem.contents, newContents: newItem.contents, - // Azure hands a file it calls binary over in an encoding of its own, so its own word on - // that is taken rather than looked for in bytes it may never have sent verbatim. + // Encoded bytes cannot identify binaries; trust Azure's metadata. binary: oldItem.isBinary || newItem.isBinary, })), ); - /** - * An iteration's changes are reported against the merge base rather than against the push before - * it, so the newest one is the whole of the change rather than the last slice of it. - */ const listLatestChanges = (input: { readonly cwd: string; readonly location: AzureDevOpsRepositoryLocation; @@ -293,8 +269,7 @@ export const make = Effect.gen(function* () { items: batch.items.map(toChangeRequest), truncated: batch.truncated, cursorAdvance: batch.cursorAdvance, - // Azure answers in one order whether or not it is being carried on from, so a slice - // can always be stepped past — by counting, which is all Azure offers. + // Azure's ordering is stable across offset-based pages. continues: true, })), ), @@ -400,8 +375,6 @@ export const make = Effect.gen(function* () { const scope = yield* diffScope(input); if (scope === null) return EMPTY_DIFF_SLICE; const cursor = parseAzureDevOpsDiffCursor(input.cursor); - // Reading on stays with the push the first slice was taken against. A push landing - // mid-read would otherwise renumber the files and hand the reader one twice, or none. const iteration = cursor === null ? scope.iterations.at(-1) @@ -421,10 +394,7 @@ export const make = Effect.gen(function* () { let edits = 0; let index = cursor?.fileIndex ?? 0; let full = false; - // Every file costs a request a side, and everything read past the point the slice fills is - // thrown away and read again by the next slice, so the batch narrows as the budgets do: - // what is left of each, over what a file has spent of it on average so far, which is the - // only estimate there is before a file is read. + // Prefetched files beyond the slice must be read again. const batchWidth = () => { if (sections.length === 0) return DIFF_FILE_CONCURRENCY; const admits = (left: number, spent: number) => @@ -445,9 +415,7 @@ export const make = Effect.gen(function* () { batch, (change) => readTexts({ cwd: input.cwd, location: scope.location, iteration, change }).pipe( - // Only what is this one file's problem. A signed-out CLI, a rate limit or no `az` - // at all is the read failing rather than the file, and belongs to the caller, - // which pauses the host rather than showing every file as unreadable. + // Auth, rate-limit and CLI failures must reach the caller. Effect.catchTags({ AzureDevOpsPullRequestNotFoundError: () => Effect.succeed(null), AzureDevOpsCommandFailedError: () => Effect.succeed(null), @@ -458,8 +426,7 @@ export const make = Effect.gen(function* () { { concurrency: DIFF_FILE_CONCURRENCY }, ); for (const { change, texts } of read) { - // The diff is synchronous and the reads no longer stand between one file and the next - // to let anything else on the server run, so the thread is handed back here. + // Synchronous diffs need yields to let other server work run. yield* Effect.yieldNow; const file = texts === null @@ -470,9 +437,7 @@ export const make = Effect.gen(function* () { edits += file.edits; truncated = truncated || file.truncated; index += 1; - // Checked after the file is added rather than before it, so every slice carries at - // least one: a section heavier than the whole budget would otherwise never be added, - // and the read would answer the same slice forever without moving the cursor. + // Add a file before checking budgets so oversized files cannot stall the cursor. if ( bytes >= MAX_DIFF_SLICE_BYTES || edits + MAX_FILE_DIFF_EDITS > MAX_DIFF_SLICE_EDITS || diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index da254510c741b..109710f881c28 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -46,9 +46,7 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], - // `az` boots a fresh Python interpreter on every invocation, so even `az --version` takes ~6s - // on Windows and overruns the default budget, leaving the provider reported as missing on - // machines where it is installed. + // `az --version` starts Python and takes ~6s on Windows, exceeding the default probe timeout. probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts index a961b4a348bd3..839935ccbd56e 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsDiff.ts @@ -3,10 +3,7 @@ import { structuredPatch } from "diff"; import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts"; -/** - * Azure hangs a pull request's changed files off an iteration, so a push landing mid-read would - * renumber the list under a cursor that did not also pin the iteration. - */ +/** Pin the iteration to prevent pushes from renumbering files during pagination. */ export interface AzureDevOpsDiffCursor { readonly iterationId: number; readonly fileIndex: number; @@ -48,19 +45,12 @@ export interface AzureDevOpsFilePatch { readonly edits: number; } -/** - * Azure hands back whole files rather than a patch, so an oversize file is paid for twice - * over before anything can be diffed. - */ +/** Azure returns whole files; oversized files incur both reads before diffing. */ const MAX_FILE_BYTES = 512 * 1024; const PATCH_CONTEXT_LINES = 3; -/** - * The line diff costs about the square of the edit distance, so an unbounded pair sharing almost - * nothing could hold the whole server. Bounded in edits rather than milliseconds so a change - * slices the same way on every machine. - */ +/** Edit distance bounds quadratic work independently of machine speed. */ export const MAX_FILE_DIFF_EDITS = 2_000; const MAX_FILE_DIFF_MILLIS = 2_000; @@ -69,10 +59,7 @@ export const MAX_DIFF_SLICE_EDITS = 6_000; export const MAX_DIFF_SLICE_BYTES = 256 * 1024; -/** - * A binary, oversize, or unreadable entry is just a header, so neither budget above stops a - * run of thousands of them. - */ +/** Header-only entries can evade the byte and edit budgets. */ export const MAX_DIFF_SLICE_FILES = 300; const NO_NEWLINE_MARKER = "\\ No newline at end of file"; @@ -90,20 +77,13 @@ function isBinary(contents: string): boolean { export const byteLength = (contents: string) => Buffer.byteLength(contents, "utf8"); -/** - * Git points an empty range at the line before it, which is line zero for a file that is wholly - * new or wholly gone, and writes a single line as its number alone. - */ +/** Git anchors empty ranges at the preceding line, which may be zero. */ function hunkRange(start: number, lines: number): string { if (lines === 0) return `${start - 1},0`; return lines === 1 ? String(start) : `${start},${lines}`; } -/** - * Azure reports no file mode, so the ordinary one stands in. Names are quoted the way git quotes - * them, prefix inside the quoting: a header reader stops a bare name at its first tab or newline, - * so a path holding either would be truncated and its viewed mark would land on the wrong file. - */ +/** Azure omits modes. Quote prefixed paths to preserve tabs/newlines. */ function patchHeader(change: AzureDevOpsChangeEntry): string { const oldSide = quoteGitPatchPath(`a/${change.oldPath}`); const newSide = quoteGitPatchPath(`b/${change.path}`); @@ -161,9 +141,7 @@ export function azureDevOpsFilePatch(input: { if (created || deleted) { const contents = created ? newContents : oldContents; const lines = contentLines(contents); - // A `+`/`-` marker on every line can put a side that just fits the size ceiling over it. - // Checked against bytes-plus-marker-count rather than building the section, since that upper - // bound is cheaper and this is exactly the file shape that would cost the most to build. + // Include per-line markers in the byte budget. if (byteLength(contents) + lines.length > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true, abandoned: false, edits: lines.length }; } @@ -187,9 +165,7 @@ export function azureDevOpsFilePatch(input: { timeout: MAX_FILE_DIFF_MILLIS, }, ); - // Hitting the edit ceiling lists the file without hunks rather than falling back to a full - // replacement: the ceiling is a distance, not a proportion, so a long file can reach it having - // changed in one small corner, and both sides in full would bury that corner in a wall of text. + // A full replacement could bury a small change that hit the edit limit. if (patch === undefined) { return { section: `${header}\n`, @@ -210,8 +186,7 @@ export function azureDevOpsFilePatch(input: { ].join("\n"); }); const section = hunks.length === 0 ? `${header}\n` : `${header}\n${hunks.join("\n")}\n`; - // The edit ceiling bounds edit distance, not the hunks' size: a handful of very long lines plus - // context can still exceed the size ceiling even well inside the edit ceiling. + // Long lines can exceed the byte budget even within the edit budget. if (byteLength(section) > MAX_FILE_BYTES) { return { section: `${header}\n`, truncated: true, abandoned: false, edits }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts index 0166570a9a623..8ed7f8641c87a 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.test.ts @@ -431,15 +431,8 @@ describe("decodeItemContentJson", () => { }); }); -/** - * These are the shapes a real organisation answered with, read back through `az devops invoke` - * rather than `az rest`: the extension hands over the route's own body with one key of its own - * added, and a live repository leaves out fields the contract documents. - */ describe("what az devops invoke answers with", () => { it("reads the envelope the extension adds its own continuation token to", () => { - // Set on every JSON body it returns, from a response header these routes do not send, so it - // arrives as null rather than not at all. expect( expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))), ).toEqual([]); @@ -470,8 +463,6 @@ describe("what az devops invoke answers with", () => { }); it("keeps the files of a change that names neither their object type nor their page after it", () => { - // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no - // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. const page = expectSuccess( decodeIterationChangesJson( asJson({ @@ -504,9 +495,6 @@ describe("what az devops invoke answers with", () => { }); it("reads a text file whose content type Azure calls a stream", () => { - // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and - // no `isBinary` at all, so the content type is not the field to ask, and its absence is the - // answer that the text is text. expect( expectSuccess( decodeItemContentJson( diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts index 05589b18fb6dd..5ed40d7731e0c 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequestJson.ts @@ -14,12 +14,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { azureDevOpsPullRequestWebUrl } from "./azureDevOpsPullRequests.ts"; -/** - * Azure's enums are decoded as plain strings and normalized here, in the same tolerant style as - * the other hosts: a new merge status must not fail a whole payload. Every field beyond the - * identity is optional, because `az repos pr` returns rather more or less of the REST object - * depending on the command. - */ +// CLI fields vary by command; unknown enum values must not fail the payload. const RawIdentitySchema = Schema.Struct({ displayName: Schema.optional(Schema.NullOr(Schema.String)), uniqueName: Schema.optional(Schema.NullOr(Schema.String)), @@ -32,11 +27,7 @@ const RawPullRequestSchema = Schema.Struct({ description: Schema.optional(Schema.NullOr(Schema.String)), status: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** - * Who armed auto-complete, which is the only thing Azure says about it: the field carries an - * identity while the pull request is set to complete on its own, and Azure leaves it out - * entirely once nobody has. So its presence is the answer, and there is no third state. - */ + /** This identity's presence indicates auto-complete. */ autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), completionOptions: Schema.optional( Schema.NullOr( @@ -143,7 +134,7 @@ function normalizeRefName(refName: string): string { return refName.trim().replace(/^refs\/heads\//, ""); } -/** A login has to compare against `az account show`, which reports an email. */ +/** Logins must match the email from `az account show`. */ function toActor(raw: Schema.Schema.Type | null | undefined) { const login = trimmed(raw?.uniqueName) ?? trimmed(raw?.displayName); return login === null @@ -257,7 +248,7 @@ type DecodeFailure = Cause.Cause; export interface AzureDevOpsPullRequestBatch { readonly items: ReadonlyArray; readonly rawIndexes: ReadonlyArray; - /** Rows Azure returned, counted before decoding, so a skipped row cannot hide a next page. */ + /** Count before decoding to avoid ending pagination on skipped rows. */ readonly rawCount: number; } @@ -282,7 +273,7 @@ export function decodePullRequestListJson( return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); } -/** Null carries "Azure answered, but with too little to use", which the caller reports. */ +/** Null means Azure returned insufficient data. */ export function decodePullRequestJson( raw: string, ): Result.Result { @@ -411,11 +402,7 @@ const decodeChangePage = decodeJsonResult(RawChangePageSchema); const decodeChangeEntry = Schema.decodeUnknownExit(RawChangeEntrySchema); const decodeItemContent = decodeJsonResult(RawItemContentSchema); -/** - * Azure leads a path with a slash that every other host and every patch omits. Not trimmed like - * the rest of this payload, since a leading/trailing space is a legal part of a file's name and - * the patch and viewed mark are keyed by the untrimmed path. - */ +/** Preserve whitespace to match patch paths and viewed marks. */ function toRepositoryPath(value: string | null | undefined): string | null { if (value === undefined || value === null) return null; const path = value.replace(/^\/+/, ""); @@ -471,8 +458,7 @@ export function decodeIterationChangesJson( if (path === null) continue; if (change.item?.isFolder === true) continue; if ((change.item?.gitObjectType ?? "blob").toLowerCase() !== "blob") continue; - // Azure names where a renamed file came from in either of two places depending on the route - // and the version, so both are read and the current path stands in when neither is there. + // Azure uses either field for renames, depending on route/version. const oldPath = toRepositoryPath(change.sourceServerItem) ?? toRepositoryPath(change.originalPath) ?? path; changes.push({ @@ -490,10 +476,7 @@ export function decodeIterationChangesJson( }); } -/** - * Azure answers an absent file with an empty body rather than an error, which reads as empty. - * Whether the bytes are text is Azure's own call, not this decoder's to guess from content. - */ +/** Azure returns an empty body for absent files. Binary status comes from its metadata. */ export function decodeItemContentJson( raw: string, ): Result.Result { diff --git a/packages/source-control-azure-devops/src/shared/definition.ts b/packages/source-control-azure-devops/src/shared/definition.ts index d977bf3c2ae00..b43adccedc846 100644 --- a/packages/source-control-azure-devops/src/shared/definition.ts +++ b/packages/source-control-azure-devops/src/shared/definition.ts @@ -125,11 +125,8 @@ export const definition = defineSourceControl({ ? { kind: KIND, name: "Azure DevOps", baseUrl: `https://${host}` } : null, /** - * `az repos pr list --repository` takes a repository name, and takes the organisation and - * project from the checkout it detects, so the recorded `org/project/_git/repo` path is refused - * outright and the whole repository reads as unavailable. The segment comes from the remote URL, - * where a name with a space reads `my%20repo`; `az` looks that up literally and answers 404, so - * it goes out decoded. + * `az repos pr list --repository` takes a name; organisation and project come from checkout. + * Decode the remote URL's name segment: `az` treats escapes literally and returns 404. */ repositorySelector: (identity) => { const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index ce630120a51cc..26891c3548974 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -664,7 +664,6 @@ it.effect("does not follow a redirect off the configured Bitbucket", () => bitbucket.request({ method: "GET", url: "/repositories/acme/web/pullrequests/1/diff" }), ); - // The client would carry every header to the new host, so the hop is checked here instead. assert.strictEqual(error._tag, "BitbucketUntrustedUrlError"); }).pipe( Effect.provide( @@ -688,7 +687,6 @@ it.effect("follows a redirect that stays on the configured Bitbucket", () => url: "/repositories/acme/web/pullrequests/1/diff", }); - // Bitbucket serves a diff as a redirect to a commit range, so the hop has to be followed. assert.strictEqual(result.body, "diff --git a/a.ts b/a.ts"); assert.isFalse(result.truncated); }).pipe( diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index d3519af097998..878aafa032997 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -236,15 +236,11 @@ export class BitbucketCheckoutError extends Schema.TaggedError()( "BitbucketUntrustedUrlError", { - /** The host only. A rejected hop is often a signed url, whose query carries a credential. */ + /** Host only; signed URL queries can contain credentials. */ host: Schema.String, }, ) { @@ -539,10 +535,7 @@ type BitbucketCredential = | { readonly kind: "access-token"; readonly accessToken: string } | { readonly kind: "api-token"; readonly email: string; readonly apiToken: string }; -/** - * 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. - */ +/** Invalid header values can throw errors containing credentials; treat them as unset. */ const HEADER_SAFE = /^[\x21-\x7e]+$/u; function credentialFrom(input: { @@ -638,7 +631,7 @@ const responseError = Effect.fn("BitbucketApi.responseError")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const host = yield* SourceControlHost.SourceControlHost; @@ -903,11 +896,7 @@ export const make = Effect.gen(function* () { return origin !== null && origin === apiOrigin ? value : null; }; - /** - * Redirects are followed here rather than by the client, which forwards every header to - * whatever host it is sent to. A pull request diff, diffstat and conflicts are all served as - * redirects, so they have to be followed — but only back to the same Bitbucket. - */ + /** Redirects forward auth headers; restrict them to the configured Bitbucket. */ const send = (input: { readonly method: "GET" | "POST" | "PUT" | "DELETE"; readonly url: string; @@ -928,7 +917,7 @@ export const make = Effect.gen(function* () { : input.method === "DELETE" ? HttpClientRequest.make("DELETE")(url) : HttpClientRequest.put(url); - // No `Accept: application/json`: the diff endpoints answer with a patch, not JSON. + // Diff endpoints return patches, not JSON. const withBody = input.body === undefined ? base diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts index 719c5279238d6..bf5ea9d944738 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.test.ts @@ -100,8 +100,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { it.effect("counts the rows it walked past as more to come", () => Effect.gen(function* () { - // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a - // hundred and drops one. const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; mockedRequest .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next)))) @@ -149,8 +147,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 }, }); - // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the - // other — and the search keeps its brackets, which is what keeps the AND out of its OR. expect(filterOfCall(0)).toBe( '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00', ); @@ -527,8 +523,6 @@ layer("BitbucketPullRequestApi.layer", (it) => { requested: true, }); - // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with - // the new one or the request would take them off it. const call = callAt(1); expect(call.method).toBe("PUT"); expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts index 7958c98290499..d5ad8491ac86b 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestApi.ts @@ -115,20 +115,13 @@ function isRepositoryPermissionUnavailableError( ); } -/** - * Asking for more does not fail — it answers with an empty page and no error at - * all, so this is a number to respect rather than to push against. - */ +/** Requests above this size return an empty page without an error. */ const MAX_PAGE_SIZE = 50; const MAX_LIST_PAGES = 10; const CONVERSATION_PAGE_SIZE = 50; const CONVERSATION_PAGES = 10; const DIFF_MAX_BYTES = 8 * 1024 * 1024; -/** - * Deliberately far shorter than the window the caller holds versions for: a refresh drops what the - * caller holds precisely so the next read reaches Bitbucket, and this must not be what answers it - * instead. - */ +/** Must expire before the caller's version cache so refreshes reach Bitbucket. */ const REVISION_PATCH_TTL = Duration.seconds(5); const REVISION_PATCH_CAPACITY = 16; export interface BitbucketPullRequestBatch { @@ -175,11 +168,7 @@ export class BitbucketPullRequestApi extends Context.Service< readonly number: number; }) => Effect.Effect; - /** - * Answers with every file the patch carries rather than only the paths asked about, since - * reading one file's version here means parsing all of them. `complete` is false for a patch - * cut short, which cannot speak for what came after the cut. - */ + /** Truncated patches cannot identify absent paths as deleted. */ readonly getFileRevisions: (input: { readonly repository: string; readonly number: number; @@ -306,10 +295,6 @@ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } -/** - * Bitbucket unions repeated `state` parameters, so a tab that spans several of its states asks - * for each. - */ function stateParams(state: PullRequestListState): ReadonlyArray { switch (state) { case "open": @@ -323,10 +308,6 @@ function stateParams(state: PullRequestListState): ReadonlyArray { } } -/** - * The parentheses matter, because the expression is ANDed with the state filter beside it and - * an unbracketed `OR` would swallow it. - */ function searchFilter(query: string): string { const literal = filterLiteral(query); return `(title ~ "${literal}" OR description ~ "${literal}")`; @@ -369,7 +350,7 @@ function bitbucketReviewPosition( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; @@ -435,10 +416,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Threads are assembled once at the end rather than per page, because a reply and the remark - * it answers can land either side of a page boundary. - */ + /** Replies and parents can span pages; assemble threads after pagination. */ const commentsPage = (input: { readonly url: string; readonly page: number; @@ -474,7 +452,7 @@ export const make = Effect.gen(function* () { body: string, ) => Result.Result<{ readonly items: ReadonlyArray; readonly next: string | null }, unknown>; readonly items: ReadonlyArray; - /** Commit pages are individually oldest-first, so older pages are prepended. */ + /** Each commit page is oldest-first. */ readonly prepend: boolean; }): Effect.Effect, BitbucketPullRequestApiError> => readPage({ operation: input.operation, url: input.url, decode: input.decode }).pipe( @@ -530,11 +508,7 @@ export const make = Effect.gen(function* () { ), ); - /** - * The parsed answer rather than the patch, which at this capacity would hold sixteen bodies - * of up to the byte ceiling each resident, and saves walking a patch of a hundred thousand - * lines again on every tick. - */ + // Cache revisions, not large patch bodies that need reparsing. const revisionPatches = yield* Cache.makeWith( (key: string) => { const [repository, number] = JSON.parse(key) as [string, number]; @@ -579,7 +553,7 @@ export const make = Effect.gen(function* () { ...(input.cursor?.seenAt ?? []).map((number) => `id != ${number}`), ]; return listPage({ - // Reviewers are not on a listing by default, and `viewerReviewRequested` needs them. + // Listings omit reviewers needed by `viewerReviewRequested`. url: `${path}/pullrequests?${stateParams(input.state) .map((state) => `state=${state}`) .join("&")}&pagelen=${MAX_PAGE_SIZE}&sort=-updated_on&fields=%2Bvalues.reviewers${ @@ -595,17 +569,13 @@ export const make = Effect.gen(function* () { withRepository(input.repository, (path) => readPage({ operation: "getPullRequest", - // `closed_on` is left out of the default representation. It is the only merge or - // decline time Bitbucket states on the pull request itself, and settlement needs it. + // Settlement needs `closed_on`, omitted by default. url: `${path}/pullrequests/${input.number}?fields=%2Bclosed_on`, decode: decodePullRequestJson, }), ), - // Bitbucket has retired permission endpoints before (CHANGE-2770), and some credentials may - // not reach `/user` endpoints at all. A 403, 404 or 410 here is read as a permission that - // could not be learned, the same as an empty page: it grants, and the write itself says why - // if the account may not do it. Other failures (a bad token, a network fault) still fail. + // Permission endpoints may return 403/404/410 (CHANGE-2770); let writes enforce access. getRepositoryPermission: (input) => withRepository(input.repository, (_path, workspace, slug) => readPage({ @@ -625,8 +595,7 @@ export const make = Effect.gen(function* () { : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( Effect.map((diff) => { const revisions = new Map(diff.revisions); - // A patch cut short at the byte ceiling says nothing about the files past the cut, - // so those paths are left out rather than reported as removed. + // A truncated patch cannot identify absent paths as deleted. if (!diff.truncated) { for (const path of input.paths) { if (!revisions.has(path)) revisions.set(path, ""); @@ -706,8 +675,7 @@ export const make = Effect.gen(function* () { const requested = new Set(pullRequest.reviewerIds); const author = pullRequest.author?.login; return { - // The author is dropped rather than shown unusable: Bitbucket refuses to make the - // person who opened a pull request its reviewer. + // Bitbucket forbids requesting a review from the PR author. candidates: members.items.flatMap((candidate) => candidate.login === author ? [] @@ -728,9 +696,7 @@ export const make = Effect.gen(function* () { decode: decodePullRequestJson, }).pipe( Effect.flatMap((current) => { - // Bitbucket has no endpoint that adds or removes one reviewer: the pull request's - // `reviewers` is written whole, so the set that is already there is read first and - // the change applied to it. + // Bitbucket replaces the entire reviewer list. const uuids = new Set(current.reviewerIds); for (const reviewer of input.reviewers) { if (input.requested) uuids.add(reviewer.id); @@ -765,10 +731,7 @@ export const make = Effect.gen(function* () { updateChangeRequest: (input) => withRepository(input.repository, (path) => - // Only the words this call rewrites travel in the body: as `setReviewerRequest` above - // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it - // was — sending `reviewers` back here would overwrite a change another user made to it - // between this call being issued and the request landing. + // Partial PUTs preserve concurrent edits to unchanged fields. bitbucket .request({ method: "PUT", @@ -809,9 +772,7 @@ export const make = Effect.gen(function* () { withRepository(input.repository, (path) => Effect.gen(function* () { const pullRequest = `${path}/pullrequests/${input.number}`; - // Bitbucket has no pending review, so a review is replayed as the requests it is - // made of: the line comments, then the summary, then the verdict. The verdict goes - // last so a review that fails part-way is never left standing as an approval. + // A partially failed review must not leave an approval. yield* Effect.forEach( input.comments, (comment) => diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts index 0a485e3ea8884..9bdf13598d9e2 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.test.ts @@ -29,7 +29,7 @@ it.effect.each([ source: { branch: { name: "release" } }, destination: { branch: { name: "master" } }, created_on: "2025-06-16T06:20:58.415108+00:00", - // Comments after a merge move this, so it is not the merge time. + // Comments after merging move this timestamp. updated_on: "2025-06-20T10:00:00.000000+00:00", closed_on: state === "OPEN" ? null : "2025-06-16T23:40:46.222238+00:00", links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/52" } }, diff --git a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts index 6d62508406291..50c7a5b85ba5a 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketPullRequestProvider.ts @@ -20,8 +20,6 @@ const CAPABILITIES: PullRequestCapabilities = { actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], search: true, - // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is - // read and none is offered. reactions: false, review: { inlineComment: true, @@ -31,10 +29,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, - // Bitbucket Cloud states nothing about what a reviewer has already read: no endpoint carries a - // viewed file, and the per-pull-request properties it does offer are one value shared by - // everyone rather than one per reader. So the marks are kept here, and the client says whose - // they are rather than implying bitbucket.org will show them. + // Bitbucket PR properties are shared, not per-reader viewed marks. viewedFiles: "environment", }; @@ -218,8 +213,7 @@ export const make = Effect.gen(function* () { closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, reviewers: pullRequest.reviewers, checks, - // Bitbucket publishes no per-repository list of allowed strategies, so the ones it - // supports are all offered and a strategy the repository forbids fails on merge. + // Bitbucket exposes no strategy permissions; unsupported strategies fail at merge. mergeCapabilities: { merge: true, squash: true, rebase: true }, viewerPermissions: bitbucketViewerPermissions({ canWrite }), }), @@ -256,7 +250,7 @@ export const make = Effect.gen(function* () { Effect.map((canWrite) => bitbucketViewerPermissions({ canWrite })), ), - // `/diff` answers with the whole patch and pages nothing, so the first slice is the last. + // `/diff` returns the whole patch without pagination. getDiff: (input) => api .getPullRequestDiff({ @@ -278,8 +272,7 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("getFileRevisions"))), - // Users only: Bitbucket requests a review of an account, and has no group that stands in for - // one on a pull request. + // Bitbucket supports user reviewers only. listReviewerCandidates: (input) => api .listReviewerCandidates({ repository: input.repository, number: input.number }) diff --git a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts index 22ad9f2190aa2..83a16eed19e84 100644 --- a/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts +++ b/packages/source-control-bitbucket/src/server/bitbucketPullRequestJson.ts @@ -20,18 +20,11 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; -/** - * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant - * style as the GitHub and GitLab decoders: a new pull request state or build status must not - * fail a whole payload. - */ +/** Unknown enum values must not fail the whole payload. */ const RawUserSchema = Schema.Struct({ - /** - * How Bitbucket addresses an account when a reviewer set is written; the handles it shows are - * not accepted there. - */ + /** Reviewer writes require UUIDs, not displayed handles. */ uuid: Schema.optional(Schema.NullOr(Schema.String)), - /** Absent on an app account, which is why `display_name` has to stand in for it. */ + /** App accounts omit this; use `display_name`. */ nickname: Schema.optional(Schema.NullOr(Schema.String)), display_name: Schema.optional(Schema.NullOr(Schema.String)), links: Schema.optional( @@ -170,14 +163,11 @@ export interface BitbucketPullRequest { readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; - /** - * Bitbucket reports no conflict state on a pull request, so the list leaves it unknown. The - * detail read asks the conflicts endpoint, which does answer. - */ + /** Listings omit conflict state; detail reads use the conflicts endpoint. */ readonly mergeability: PullRequestMergeability; readonly createdAt: string; readonly updatedAt: string; - /** When the request was merged or declined, unlike `updatedAt`, which later comments move. */ + /** Merge/decline time; later comments move `updatedAt`. */ readonly closedAt: string | null; readonly body: string; readonly reviewRequestLogins: ReadonlyArray; @@ -191,11 +181,7 @@ function trimmed(value: string | null | undefined): string | null { return text.length > 0 ? text : null; } -/** - * Bitbucket stamps times as `+00:00` with microseconds. The page sorts change requests from - * every host against each other as plain strings, so they are normalized to the same `Z` form - * the other hosts already use. - */ +/** Normalize Bitbucket's microsecond `+00:00` timestamps to `Z` for cross-host string sorting. */ function toIsoUtc(value: string): string { return Option.match(DateTime.make(value), { onNone: () => value, @@ -354,11 +340,7 @@ export function decodeRepositoryPermissionJson(raw: string): Result.Result, DecodeFailure> { @@ -382,10 +364,7 @@ export type BitbucketRawComment = Schema.Schema.Type; export interface BitbucketComments { readonly comments: ReadonlyArray; - /** - * The same comments unread, for `buildReviewThreads`. A reply and the remark it answers can - * land on different pages, and only the caller holding every page can put them together. - */ + /** Raw entries for thread assembly across page boundaries. */ readonly entries: ReadonlyArray; readonly next: string | null; } @@ -523,10 +502,7 @@ export function decodeStatusesJson( const status = decodedStatus.value; const name = trimmed(status.name) ?? trimmed(status.key); if (name === null) continue; - // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear - // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, - // which is the order Bitbucket writes an update in. The key is kept as the workflow name so - // two different pipelines that display the same name are not folded into one. + // Reruns reuse keys; later entries win. Keys distinguish same-named pipelines. checks.push({ check: { name, diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 7559ff879b4ea..946098b166d34 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -78,7 +78,7 @@ export const definition = defineSourceControl({ publicHost: "bitbucket.org", publishDescription: "bitbucket.org", publishHost: () => "bitbucket.org", - // A new repository belongs to a workspace, which the signed-in account does not name. + // The signed-in account does not identify a workspace. newRepositoryOwner: () => null, defaultCloneTransport: "ssh", changeRequestUrl: ({ host, repository, number, webUrl }) => @@ -91,7 +91,7 @@ export const definition = defineSourceControl({ // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. changeRequestActions: new Set(["merge", "close"] as const), - // Bitbucket has no checkout CLI, so clone the head branch from its own repository. + // Bitbucket has no checkout CLI. checkoutCommand: ({ number, headBranch, headRepositoryNameWithOwner }) => headRepositoryNameWithOwner && repositoryName.test(headRepositoryNameWithOwner) && diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 539134061759f..ce1a72c1aedf6 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -113,9 +113,7 @@ export interface ProviderChangeRequestSummary { readonly reviewDecision?: PullRequestReviewDecision | null | undefined; readonly checksState?: PullRequestChecksState | null | undefined; readonly mergeability?: PullRequestMergeability | undefined; - /** - * Null when the host says it is in none; absent when the read did not ask. - */ + /** Null means no stack; absent means not requested. */ readonly stack?: PullRequestStackMembership | null | undefined; } @@ -139,10 +137,7 @@ export interface ProviderChangeRequestStack { export interface ProviderChangeRequestPage { readonly items: ReadonlyArray; readonly truncated: boolean; - /** - * Most hosts advance by the rows delivered after local de-duplication; an offset-paged host - * may need to count malformed raw rows it consumed too. - */ + /** Offset-paged hosts must include malformed raw rows in their advance count. */ readonly cursorAdvance?: number; /** * Where a host that can only page from the top carries on: an opaque position past the last @@ -154,11 +149,7 @@ export interface ProviderChangeRequestPage { } export interface ProviderListCursor { - /** - * Asked for inclusively: several rows share one instant often enough — a bot that touches eight - * change requests writes one timestamp on all eight — and asking for strictly older would lose - * whichever of them the slice ended before. - */ + /** Inclusive boundary: equal-time rows can span pages and must not be skipped. */ readonly updatedBefore: string; readonly delivered: number; /** @@ -221,7 +212,7 @@ export interface ProviderChangeRequestActivity { export interface ProviderDiffSlice { readonly patch: string; - /** Something in this slice could not be shown, as opposed to there being more slices. */ + /** Content was omitted within this slice. */ readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats?: ReadonlyArray; @@ -232,9 +223,14 @@ export interface ProviderDiffFileContents { readonly newContents: string; } +export interface ProviderDiffFilePaths { + readonly files: ReadonlyArray<{ readonly oldPath: string; readonly newPath: string }>; + readonly nextCursor: string | null; +} + export interface ProviderFilesViewed { readonly files: ReadonlyArray; - /** The host has more files than were read, so the ones missing here are not "unviewed". */ + /** More files exist; missing entries are unknown, not "unviewed". */ readonly truncated: boolean; } @@ -255,11 +251,7 @@ export interface ProviderFileStats { */ export interface ProviderFileRevisions { readonly revisions: ReadonlyMap; - /** - * A host with no per-file version reads the whole change to answer for one file, and saying so - * is what keeps the next tick, naming a path nothing asked about before, from making it read - * the whole change again. False for a read cut short, which cannot speak past the cut. - */ + /** All revisions were read and can be reused for other paths. */ readonly complete?: boolean; } @@ -301,13 +293,7 @@ export interface PullRequestProviderApi { readonly unauthenticated: string; }; readonly mergeMessageRewrite?: (message: string) => string; - /** - * Absent means `owner/name` on the host identifies it, and any checkout on the host can serve a - * change request in another repository there. - * - * A host that resolves it (Azure DevOps: `dev.azure.com/org/project/_git/repo`) derives its - * organization from the checkout, so only a checkout whose key matches the reference serves it. - */ + /** Use matching checkout keys instead of `owner/name`. */ readonly repositoryKey?: (input: { readonly canonicalKey: string }) => string; /** * Names the repository in every request and authenticates by host, so a change request on the @@ -326,28 +312,15 @@ export interface PullRequestProviderApi { readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; - /** - * A host with no text filter of its own ignores it and answers with the page it - * would have answered with anyway — the caller narrows what it gets, so an - * unfiltered page is a wider answer rather than a wrong one. - */ + /** Hosts may ignore unsupported search; the caller filters returned rows. */ readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; - /** - * Further narrowings, which a host applies as far as it can and ignores the rest of — - * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows - * what it gets for the fields a row carries. - */ + /** The caller applies any filters the host ignores. */ readonly filters?: PullRequestListFilters | undefined; }, ) => Effect.Effect; - /** - * `limit` is the whole slice rather than a size per repository, because that is the shape of - * the answer: the newest `limit` rows across every repository named, which is exactly the - * rows a page ordered by update shows. `cursor` is one boundary for all of them, so a caller - * with repositories standing at different boundaries asks in groups rather than in one call. - */ + /** Newest `limit` rows total across repositories, under one cursor boundary. */ readonly listChangeRequestsAcross?: (input: { readonly cwd: string; readonly host: string; @@ -389,10 +362,7 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; - /** - * Null when the host gave no answer for it. Optional: a host without one has - * its watched change requests read in full on every pass. - */ + /** Null means unknown; an absent method requires full watch reads. */ readonly getChangeRequestWatchFingerprint?: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; @@ -413,12 +383,7 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * Asked before anything is written, so a request that reached the server without going past - * the page is refused by what the host says rather than by what the client claimed — and - * asked freshly, because access granted or taken away since the page loaded is exactly the - * case this guards. - */ + /** Read fresh before writes; client permissions may be stale or bypassed. */ readonly getViewerPermissions: ( input: ProviderRepositoryRef & { readonly number: number; @@ -434,6 +399,15 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** Exact changed path pairs, including renames, without fetching patch contents. */ + readonly getDiffFilePaths?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly cursor?: string | undefined; + readonly commit?: string | undefined; + }, + ) => Effect.Effect; + readonly getDiffFileContents?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -464,11 +438,7 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * Required of a host whose `capabilities.viewedFiles` is `"environment"`, and unused by one - * that keeps the marks itself: the marks live here, but only the host can say whether what a - * reader cleared last week is still what is in front of them. - */ + /** Required for `environment` viewed marks to invalidate them after changes. */ readonly getFileRevisions?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -488,11 +458,7 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * Only called when `capabilities.edit.changeRequest` is true, and never with both fields - * absent — the caller refuses that before it gets here, because a host asked to change - * nothing answers differently on each of them. - */ + /** Requires edit capability and at least one field. */ readonly updateChangeRequest?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -505,11 +471,6 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, ) => Effect.Effect; - /** - * Whether this remark is the reader's to rewrite is the host's own answer: no read here can - * settle it, since access can be taken away between the conversation being read and the - * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. - */ readonly updateComment?: ( input: ProviderRepositoryRef & { readonly number: number; @@ -528,11 +489,7 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * The author is left out by each provider rather than by the caller, because only the - * provider knows how the host spells the same person in a candidate list and on a pull - * request. - */ + /** Exclude the author using host-specific account identities. */ readonly listReviewerCandidates: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; @@ -584,10 +541,7 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; - /** - * `subjectId` is a remark's id as the conversation carried it; absent means the change - * request itself, whose reactions sit on its description. - */ + /** `subjectId` is the conversation's remark ID; absent targets the change request description. */ readonly setReaction: ( input: ProviderRepositoryRef & { readonly number: number; diff --git a/packages/source-control-core/src/server/SourceControlHost.ts b/packages/source-control-core/src/server/SourceControlHost.ts index 711538d0aa7e9..e04b602f1af67 100644 --- a/packages/source-control-core/src/server/SourceControlHost.ts +++ b/packages/source-control-core/src/server/SourceControlHost.ts @@ -65,7 +65,7 @@ export class SourceControlHost extends Context.Service< SourceControlHost, { readonly settings: { - /** Read fresh on each call, so a credential saved in Settings applies without a restart. */ + /** Read fresh on each call to pick up credential changes. */ readonly get: Effect.Effect; }; readonly process: { @@ -85,10 +85,7 @@ export class SourceControlHost extends Context.Service< readonly cwd: string; readonly revision: string; }) => Effect.Effect<{ readonly commitSha: string }, GitCommandError>; - /** - * The outer effect resolves the repository and fails when the server cannot drive - * one there; the inner one lists its remotes. - */ + /** The outer effect resolves the repository; the inner effect lists its remotes. */ readonly remotes: ( cwd: string, ) => Effect.Effect, VcsError>; diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index f5f2c2d786b47..76eb90cfd8c0e 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -117,10 +117,7 @@ export class SourceControlProvider extends Context.Service< SourceControlProvider, { readonly kind: SourceControlProviderKind; - /** - * The caller checks the owner against what comes back, so a host that cannot search - * `owner:branch` can drop those selectors without losing anything. - */ + /** The caller verifies owners, so hosts may drop unsupported `owner:branch` selectors. */ readonly headBranchProbe?: (input: { readonly headSelectors: ReadonlyArray; readonly state: "open" | "all"; @@ -129,10 +126,7 @@ export class SourceControlProvider extends Context.Service< readonly cwd: string; readonly treeish: string; }) => Effect.Effect>; - /** - * The repository's `owner/name` from a remote URL, for a host whose remote paths can carry - * more than that (Forgejo's HTTP installation mount). Absent means the whole path. - */ + /** Strip installation mounts from remote paths; absent means use the whole path. */ readonly repositoryNameFromRemoteUrl?: (url: string) => string | null; readonly refineRepositoryIdentity?: (input: { readonly identity: RepositoryIdentity; diff --git a/packages/source-control-core/src/server/SourceControlRateLimit.ts b/packages/source-control-core/src/server/SourceControlRateLimit.ts index 82d4d30b44d2f..fd1c4ab1e002a 100644 --- a/packages/source-control-core/src/server/SourceControlRateLimit.ts +++ b/packages/source-control-core/src/server/SourceControlRateLimit.ts @@ -20,11 +20,7 @@ export const CredentialScope = Context.Reference( }, ); -/** - * A host may let requests made under it spend a reserved quota and go through a rate-limit - * pause: a user acting on a change request should not be refused because a background read - * exhausted the quota. - */ +/** Interactive requests may use reserved quota and bypass pauses caused by background reads. */ export const Interactive = Context.Reference( "@t3tools/source-control-core/server/SourceControlRateLimit/Interactive", { defaultValue: () => false }, @@ -99,7 +95,7 @@ export function retryAtFromHeader(value: string | undefined, now: number): numbe return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const entries = yield* Ref.make>(new Map()); diff --git a/packages/source-control-core/src/server/diffFileRevisions.test.ts b/packages/source-control-core/src/server/diffFileRevisions.test.ts index 5a4131db88e04..b0ebae78b2a66 100644 --- a/packages/source-control-core/src/server/diffFileRevisions.test.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.test.ts @@ -181,8 +181,6 @@ describe("parseDiffFileRevisions", () => { }); it("keeps a character from outside the basic plane that git left unescaped", () => { - // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header - // for the tab. Encoding what it left one unit at a time would split the pair into two halves. const revisions = parseDiffFileRevisions( patchOf( 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"', diff --git a/packages/source-control-core/src/server/diffFileRevisions.ts b/packages/source-control-core/src/server/diffFileRevisions.ts index 2aa683e860c00..49c043f9a04f3 100644 --- a/packages/source-control-core/src/server/diffFileRevisions.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.ts @@ -8,7 +8,7 @@ interface Entry { newPath: string | null; deleted: boolean; revision: string | null; - /** Past the first hunk header every line is content, and content can start like a header. */ + /** After the first hunk, content can resemble file headers. */ inBody: boolean; } @@ -24,11 +24,7 @@ function quotedEnd(rest: string): number { return -1; } -/** - * Git ends the name with a tab when it holds a space, and a name with a tab of its own - * arrives quoted with that tab escaped, so the first literal tab is never part of what the - * file is called. - */ +/** Git terminates names with a literal tab and escapes tabs within names. */ function sidePath(rest: string, prefix: string): string | null { const tab = rest.indexOf("\t"); const token = tab === -1 ? rest : rest.slice(0, tab); @@ -42,11 +38,7 @@ function headerSide(token: string, prefix: string): string | null { return path.startsWith(prefix) ? path.slice(prefix.length) : null; } -/** - * `a/one two b/one two` can split in more than one place, so the split leaving both sides - * equal wins; a rename (the only case where sides differ) states its names on separate lines - * instead. - */ +/** Match equal sides for ambiguous unquoted paths; renames state paths separately. */ function headerPaths(rest: string): readonly [string | null, string | null] { if (rest.startsWith(QUOTE)) { const end = quotedEnd(rest); @@ -117,12 +109,7 @@ function parseEntries(patch: string): ReadonlyArray> { return entries; } -/** - * What the head has of each file, as the blob ids from a unified patch's `index ..` - * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except - * for a deletion where only the old name exists. An entry with no `index` line (most often one - * Bitbucket excluded by pattern) is left out. - */ +/** Head blob IDs by head path (old path for deletions); omit files without index lines. */ export function parseDiffFileRevisions(patch: string): ReadonlyMap { const revisions = new Map(); for (const entry of parseEntries(patch)) { @@ -134,10 +121,6 @@ export function parseDiffFileRevisions(patch: string): ReadonlyMap { diff --git a/packages/source-control-core/src/server/discovery.ts b/packages/source-control-core/src/server/discovery.ts index b03f6a6efa4d0..66889ddc4f2ab 100644 --- a/packages/source-control-core/src/server/discovery.ts +++ b/packages/source-control-core/src/server/discovery.ts @@ -58,10 +58,7 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly type: "managed-cli"; readonly probe: (cwd: string) => Effect.Effect; - /** - * `null` when the integration holds nothing for the remote, which includes its CLI not being - * installed. A failure means it could not tell, and the registry does not keep that answer. - */ + /** Null includes missing CLIs. Failure means unknown and must not be cached by the registry. */ readonly refineUnknownRemote: (input: { readonly cwd: string; readonly context: SourceControlProvider.SourceControlProviderContext; @@ -301,10 +298,7 @@ export function probeSourceControlProvider(input: { const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError); -/** - * A hosting CLI that is not installed claims no remote. The spawner checks the cwd before the - * executable, so only the executable's own ENOENT comes from `ChildProcess`. - */ +/** The spawner checks cwd first, so ChildProcess NotFound identifies the executable. */ export function isMissingExecutable(error: unknown): boolean { return ( isVcsProcessSpawnError(error) && @@ -368,8 +362,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide const results = yield* Effect.forEach(input.specs, (spec) => refineWithSpec({ ...input, spec }).pipe(Effect.result), ); - // A claim reached after another integration could not answer is not settled: that one might - // have claimed the host first. + // An earlier integration that could not answer might have claimed this host. let settled = true; for (const result of results) { if (Result.isFailure(result)) settled = false; diff --git a/packages/source-control-core/src/server/pullRequestChecks.test.ts b/packages/source-control-core/src/server/pullRequestChecks.test.ts index 21616059ca876..d4218213dca28 100644 --- a/packages/source-control-core/src/server/pullRequestChecks.test.ts +++ b/packages/source-control-core/src/server/pullRequestChecks.test.ts @@ -170,7 +170,6 @@ describe("dedupeChecks", () => { }); it("leaves a colliding check with no workflow of its own unqualified", () => { - // An app-provided check run belongs to no workflow, which GitHub reports as an empty name. const checks = dedupeChecks([ entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }), entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), diff --git a/packages/source-control-core/src/server/pullRequestChecks.ts b/packages/source-control-core/src/server/pullRequestChecks.ts index 159b802cd4c2b..ed4b748d4e37d 100644 --- a/packages/source-control-core/src/server/pullRequestChecks.ts +++ b/packages/source-control-core/src/server/pullRequestChecks.ts @@ -47,11 +47,8 @@ export function sortChecks( } /** - * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or - * while a second run of it is already live — the same check arrives twice, and both copies reach - * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what - * identifies it, qualified by the workflow it belongs to since two workflows are free to name a - * job the same thing. + * Rollups repeat checks during reruns or overlapping runs, and checks carry no ids. + * Identify checks by workflow plus name because different workflows may share job names. */ export function dedupeChecks( entries: ReadonlyArray<{ diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 8d25856e5dc3c..6a9f3d23bc6f6 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -234,6 +234,11 @@ export interface SourceControlDefinition { * sends T3 no notification of its own. Absent for a host with no such CLI. */ readonly changeRequestCloseCommand?: RegExp; + /** + * Matches a shell command that opens a change request with this host's CLI and prints its URL + * on a line of its own. Absent for a host with no such CLI. + */ + readonly changeRequestCreateCommand?: RegExp; readonly authorProfileUrl: (login: string, repositoryUrl: string) => string | null; /** * The references this host's own renderer links in plain text, such as `#12` or a commit SHA, @@ -566,12 +571,9 @@ function inOrder( } /** - * `definitions` is display order, and its first entry is what `get` returns for a repository - * that has not reported its host yet. A change request URL goes to the first definition that - * reads it, trying `claimChangeRequestUrlsFirst` before the rest, so a host that claims a route - * only on its own hostnames can precede one that claims the route everywhere. A remote likewise - * goes to the first that recognises it, in `recognizeRemotesIn` order and then the rest, because - * one hostname can carry several hosts' labels. + * `definitions` sets display order and the fallback for repositories with an unknown host. + * Claim host-specific URL routes before hosts that accept them on any hostname. + * Remote recognition needs a separate order because one hostname can carry several hosts' labels. */ export function makeSourceControlDefinitionRegistry( definitions: ReadonlyArray, diff --git a/packages/source-control-definitions/src/composerPullRequestMatches.ts b/packages/source-control-definitions/src/composerPullRequestMatches.ts index a399fc53da54a..ad2aa3f4bb313 100644 --- a/packages/source-control-definitions/src/composerPullRequestMatches.ts +++ b/packages/source-control-definitions/src/composerPullRequestMatches.ts @@ -83,8 +83,7 @@ export function filterComposerPullRequestMatches< } const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query; const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry)); - // `.sort()` on a copy, not `.toSorted()`: this runs on Hermes, which has no ES2023 array - // methods, and reaching for one here crashed the composer as the suggestions loaded. + // Hermes has no ES2023 array methods, so sort a fresh array. return [...uniqueEntries.values()] .sort( (left, right) => diff --git a/packages/source-control-definitions/src/registry.ts b/packages/source-control-definitions/src/registry.ts index 0f213ab67a9ac..0e42360855350 100644 --- a/packages/source-control-definitions/src/registry.ts +++ b/packages/source-control-definitions/src/registry.ts @@ -6,14 +6,12 @@ import * as GitCafe from "@t3tools/source-control-gitcafe/shared/definition"; import * as GitHub from "@t3tools/source-control-github/shared/definition"; import * as GitLab from "@t3tools/source-control-gitlab/shared/definition"; +export { canonicalizePullRequestUrl as canonicalizeGitCafePullRequestUrl } from "@t3tools/source-control-gitcafe/shared/definition"; + /** - * The server and every client read hosts through this one registry, so they agree on which host - * owns a URL or remote. GitHub comes first because a repository that has not reported its host - * yet reads as GitHub, which is what clients have always shown there. A change request URL goes - * to the first host whose shape it has, so GitCafe, which claims `/pull/` and `/pulls/` only on - * its own hostnames, claims before GitHub and Forgejo, which claim them everywhere. A hostname can - * carry more than one host's label (`gitea.github.example`), so remote recognition has its own - * order. + * GitHub comes first to preserve the client default for repositories with an unknown host. + * GitCafe claims URLs first because GitHub and Forgejo accept the same routes on any hostname. + * Remote recognition needs its own order because host labels can overlap (`gitea.github.example`). */ export const builtInSourceControlDefinitions = makeSourceControlDefinitionRegistry( [ diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 8403e672503a2..3d293987d3223 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -155,7 +155,7 @@ export const make = Effect.gen(function* () { more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)), }; }); - // Use a stable, small page size that also works with Forgejo's default maximum of 50. + // Forgejo's default page limit is 50. const page = Effect.fn("ForgejoPullRequestProvider.page")(function* ( input: ForgejoCli.ForgejoApiInput, schema: Schema.Codec, @@ -373,7 +373,7 @@ export const make = Effect.gen(function* () { })), ), page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview), - // Without these, each commit lists its files and a big merge outgrows the output limit. + // Commit file lists can exceed the output limit on large merges. page( { ...input, path: `${pullPath(input)}/commits?files=false&verification=false` }, ForgejoCommit, diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index e44e41b880149..26e90d40b23eb 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -193,10 +193,7 @@ export function repositoryNameFromRemoteUrl(url: string): string | null { : path.split("/").slice(-2).join("/"); } -/** - * A Forgejo remote's URL can't say which server it belongs to (an SSH alias, an installation - * mount), so the identity's browser URL comes from the login that serves it. - */ +// Resolve browser URLs from the serving login to handle SSH aliases and mounts. const refineRepositoryIdentity: NonNullable< SourceControlProvider.SourceControlProvider["Service"]["refineRepositoryIdentity"] > = Effect.fn("ForgejoSourceControlProvider.refineRepositoryIdentity")(function* ({ @@ -483,7 +480,7 @@ export const make = Effect.gen(function* () { ], }); if (input.force) { - // The sha comes from the server and git would read anything but a commit as an option. + // Non-commit server input could become git options. if (!/^[0-9a-f]{7,64}$/i.test(pull.head.sha)) return yield* new ForgejoCli.ForgejoCliError({ command: repo.command ?? "tea", @@ -491,8 +488,7 @@ export const make = Effect.gen(function* () { detail: "Forgejo reported a pull request head that is not a commit.", reason: "invalid-response", }); - // tea leaves an existing PR branch at its old tip. Keep dirty files safe - // while bringing the selected branch to the PR revision we fetched. + // tea leaves PR branches stale; `--keep` updates them without discarding dirty files. yield* process.run({ operation: "ForgejoSourceControlProvider.checkoutChangeRequest", command: "git", diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index c303ac80a4682..32258854c79da 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -83,7 +83,7 @@ export const definition = defineSourceControl({ }, changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), - // Neither `fj` nor `tea` checks out by number, so fetch the pull ref from the repository itself. + // Neither `fj` nor `tea` checks out by number. checkoutCommand: ({ number, repositoryUrl }) => repositoryUrl ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' ${changeRequestHeadRef(number)} && git checkout -B pulls/${number} FETCH_HEAD` diff --git a/packages/source-control-gitcafe/src/server/GitCafeApi.ts b/packages/source-control-gitcafe/src/server/GitCafeApi.ts index 581ae677a2876..614091ede7015 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeApi.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeApi.ts @@ -85,7 +85,7 @@ const credentialFailure = ( ? "unauthenticated" : "failed"; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const credentials = yield* GitCafeCredentials.GitCafeCredentials; @@ -102,7 +102,7 @@ export const make = Effect.gen(function* () { detail: fields.detail, ...(fields.cause === undefined ? {} : { cause: fields.cause }), }); - // The host comes from a remote URL, so a token never leaves for one GitCafe does not own. + // Remote URLs must not send tokens outside GitCafe's hosts. const host = GitCafeHosts.gitCafeHost(input.host); if (host === null) return yield* fail({ @@ -134,10 +134,9 @@ export const make = Effect.gen(function* () { input.body === undefined ? base : base.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)), ) .pipe( - // The client's own span would record the query string, which can carry branch names. + // Client spans expose query strings containing branch names. Effect.provideService(HttpClient.TracerDisabledWhen, () => true), - // fetch would follow a redirect itself and keep the bearer token on a same-origin hop; - // GitCafe's API never redirects a request it serves, so a 3xx is answered as a failure. + // GitCafe does not redirect; reject 3xx to protect bearer credentials. Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), ); return { status: response.status, text: yield* response.text }; diff --git a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts index 5cfcd9d8b8f79..de0133d303a9e 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeCredentials.ts @@ -69,10 +69,7 @@ export class GitCafeCredentials extends Context.Service< const normalizeHost = (host: string) => host.trim().toLowerCase(); const DEFAULT_HOST = "git.cafe"; -/** - * `CAFE_TOKEN`, but only for the host `cafe` itself would send it to (`CAFE_HOST`, git.cafe by - * default): a remote URL picks the host, and must not steer a production token to staging. - */ +/** Restrict CAFE_TOKEN to CAFE_HOST (default git.cafe), including for staging remotes. */ export function environmentToken( host: string, env: Readonly>, @@ -92,18 +89,14 @@ function credentialPassword(output: string): string | null { return null; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const { process } = sourceControlHost; const environment = yield* HostProcess.Environment; const workingDirectory = yield* HostProcess.WorkingDirectory; - /** - * `cafe` has no `auth token` command; its Git credential helper is the supported way to hand - * the stored login to another program, and answers over stdin/stdout so the token never - * reaches an argument list or a log. - */ + /** cafe has no auth-token command; its credential helper keeps tokens out of argv/logs. */ const fromCafe = (host: string) => process .run({ diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts index 3a9ef4e852b71..d787b2ba62ac8 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.test.ts @@ -164,9 +164,292 @@ const assertRequestId = (body: unknown) => /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u, ); +describe("GitCafePullRequestProvider changed paths", () => { + const snapshot = { version: pull.version, headOid, comparisonBaseOid: baseOid }; + + it.effect("pages changed paths without fetching diff-file hunks", () => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + items: request.query.has("after") + ? [{ path: "gone.ts", status: "deleted" }] + : [{ path: "new name.ts", oldPath: "old name.ts", status: "renamed" }], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider.getDiffFilePaths!(target); + const second = yield* provider.getDiffFilePaths!({ ...target, cursor: first.nextCursor! }); + assert.deepStrictEqual(first.files, [{ oldPath: "old name.ts", newPath: "new name.ts" }]); + assert.deepStrictEqual(second, { + files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], + nextCursor: null, + }); + assert.isFalse(server.sent.some((request) => request.path.includes("/diff-file"))); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s rejects a repeated continuation cursor", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: "same-page" }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const first = yield* provider[method]!(target); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + // getDiff cases guard parity with the existing patch path; getDiffFilePaths must refuse the same pages. + it.effect.each( + (["getDiff", "getDiffFilePaths"] as const).flatMap((method) => + [{ version: 5 }, { headOid: baseOid }, { comparisonBaseOid: headOid }].map((changed) => ({ + method, + changed, + })), + ), + )( + "$method refuses a continuation from a different revision ($changed)", + ({ method, changed }) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": (request: SentRequest) => ({ + ...snapshot, + ...(request.query.has("after") ? changed : {}), + items: [], + next: request.query.has("after") ? null : "next-page", + }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const read = (cursor?: string) => + method === "getDiff" + ? provider.getDiff({ ...target, ...(cursor === undefined ? {} : { cursor }) }) + : provider.getDiffFilePaths!({ + ...target, + ...(cursor === undefined ? {} : { cursor }), + }); + const first = yield* read(); + const input = { ...target, cursor: first.nextCursor! }; + const result = + method === "getDiff" + ? yield* provider.getDiff(input).pipe(Effect.result) + : yield* provider.getDiffFilePaths!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each(["getDiff", "getDiffFilePaths"] as const)( + "%s reads a pull that reports no head yet", + (method) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": { ...pull, headOid: null }, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider[method]!(target); + assert.isNull(result.nextCursor); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each([{ version: 5 }, { headOid: baseOid }])( + "refuses initial changed paths from a different pull revision (%j)", + (changed) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, ...changed, items: [], next: null }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFilePaths!(target).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + }).pipe(Effect.provide(server.layer)); + }, + ); +}); + +describe("GitCafePullRequestProvider source contents", () => { + const comparisonBase = "a".repeat(40); + const snapshot = { version: pull.version, headOid, comparisonBaseOid: comparisonBase }; + const input = { + ...target, + changeType: "rename-changed" as const, + oldPath: "old name.ts", + newPath: "src/new #name.ts", + }; + const blob = (request: SentRequest) => ({ + commit: request.query.get("oid"), + oid: "b".repeat(40), + path: request.query.get("path"), + size: 16, + binary: false, + content: "const value = 1;", + }); + const routes = { + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/changes": snapshot, + "GET /repos/owner/repo/blob": blob, + }; + + it.effect( + "pins rename source to the comparison base and fork head, without reading branches", + () => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/pulls/7": { + ...pull, + sourceRepo: { owner: "fork", name: "project" }, + }, + "GET /repos/fork/project/blob": blob, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input); + assert.deepStrictEqual(result, { + oldContents: "const value = 1;", + newContents: "const value = 1;", + }); + const changes = server.sent.find((request) => request.path.endsWith("/changes"))!; + assert.strictEqual(changes.query.get("expectedVersion"), "4"); + assert.strictEqual(changes.query.get("limit"), "1"); + assert.deepStrictEqual( + server.sent + .filter((request) => request.path.endsWith("/blob")) + .map((request) => ({ + path: request.path, + oid: request.query.get("oid"), + file: request.query.get("path"), + ref: request.query.get("ref"), + })), + [ + { path: "/repos/owner/repo/blob", oid: comparisonBase, file: input.oldPath, ref: null }, + { path: "/repos/fork/project/blob", oid: headOid, file: input.newPath, ref: null }, + ], + ); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each(["new", "deleted"] as const)( + "skips the absent side of a %s file", + (changeType) => { + const server = fakeGitCafe(routes); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!({ ...input, changeType }); + assert.strictEqual(result[changeType === "new" ? "oldContents" : "newContents"], ""); + assert.lengthOf( + server.sent.filter((request) => request.path.endsWith("/blob")), + 1, + ); + }).pipe(Effect.provide(server.layer)); + }, + ); + + it.effect.each([ + { ...snapshot, version: 5 }, + { ...snapshot, headOid: baseOid }, + { ...snapshot, comparisonBaseOid: "main" }, + ])("refuses a changed or nonimmutable comparison snapshot", (response) => { + const server = fakeGitCafe({ ...routes, "GET /repos/owner/repo/pulls/7/changes": response }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.isFalse(server.sent.some((request) => request.path.endsWith("/blob"))); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + { binary: true, content: null }, + { content: "binary\0data" }, + { size: 1_048_577 }, + { size: 1, content: "x".repeat(1_048_577) }, + { size: 1, content: "é".repeat(524_289) }, + { commit: baseOid }, + { path: "another.ts" }, + { content: null }, + ])("refuses unavailable, oversized, binary, or mismatched blobs", (fields) => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": (request: SentRequest) => ({ ...blob(request), ...fields }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!(input).pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.writes(), []); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect.each([ + [413, "oversized"], + [501, "unavailable"], + ] as const)("marks HTTP %s blob refusals permanently unavailable", ([status, reason]) => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": new Response("{}", { status }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getDiffFileContents!({ ...input, changeType: "new" }).pipe( + Effect.flip, + ); + assert.deepInclude(error.cause as object, { + _tag: "GitCafeDiffSourceUnavailableError", + reason, + }); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect("preserves authentication failures from blob access", () => { + const server = fakeGitCafe({ + ...routes, + "GET /repos/owner/repo/blob": new Response("{}", { status: 401 }), + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const error = yield* provider.getDiffFileContents!({ ...input, changeType: "new" }).pipe( + Effect.flip, + ); + assert.strictEqual(error.reason, "unauthenticated"); + assert.strictEqual(error.operation, "getDiffFileContents"); + }).pipe(Effect.provide(server.layer)); + }); + + it.effect( + "refuses commit-specific contents consistently with unsupported GitCafe commit diffs", + () => { + const server = fakeGitCafe({}); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + const result = yield* provider.getDiffFileContents!({ ...input, commit: headOid }).pipe( + Effect.result, + ); + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual(server.sent, []); + }).pipe(Effect.provide(server.layer)); + }, + ); +}); + describe("GitCafePullRequestProvider", () => { it.effect("keeps a staging repository's requests and links on staging", () => { - // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target. const server = fakeGitCafe( { "GET /repos/owner/repo/pulls/7": pull }, { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } }, @@ -648,6 +931,57 @@ describe("GitCafePullRequestProvider", () => { }).pipe(Effect.provide(server.layer)); }); + it.effect("reads the checks state from each check's newest run", () => { + const run = (id: string, conclusion: string) => ({ + id, + name: "Check", + status: "completed", + conclusion, + summary: null, + detailsUrl: null, + }); + const items = [run("120859457782", "success"), run("120857059247", "failure")]; + const detailWith = (checks: (request: SentRequest) => unknown) => { + const server = fakeGitCafe({ + "GET /repos/owner/repo/pulls/7": pull, + "GET /repos/owner/repo/pulls/7/reviewers": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/labels": { items: [], next: null }, + "GET /repos/owner/repo/pulls/7/status": { + merge: { conflicts: "none", fastForward: null, strategies: [] }, + // GitCafe counts the cancelled run's failure beside the re-run that passed. + checks: { pending: 0, failing: 1, total: 2 }, + }, + [`GET /repos/owner/repo/commits/${headOid}/checks`]: checks, + "GET /repos/owner/repo/pulls/7/changes": { + version: 4, + headOid, + comparisonBaseOid: baseOid, + items: [], + next: null, + }, + }); + return Effect.gen(function* () { + const provider = yield* GitCafePullRequestProvider.make; + return yield* provider.getChangeRequest(target); + }).pipe(Effect.provide(server.layer)); + }; + return Effect.gen(function* () { + const detail = yield* detailWith(() => ({ items, next: null })); + assert.deepStrictEqual( + detail.checks.map((check) => [check.name, check.status]), + [["Check", "success"]], + ); + assert.strictEqual(detail.checksState, "passing"); + + // A listing cut off at the page limit may hide a failure, so GitCafe's counts decide. + const truncated = yield* detailWith((request) => ({ + items, + next: String(Number(request.query.get("after") ?? 0) + 1), + })); + assert.strictEqual(truncated.checksState, "failing"); + }); + }); + it.effect("still reads a pull request whose reviewers the token may not see", () => { const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7": { ...pull, headOid: null }, diff --git a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts index 04bf4c4b595ff..f2aa82fdcb661 100644 --- a/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafePullRequestProvider.ts @@ -27,7 +27,7 @@ const CAPABILITIES: PullRequestCapabilities = { comment: true, actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], - // Only through a stack: GitCafe's one branch update restacks every layer above it. + // Branch updates restack every layer above the target. updateMethods: ["rebase"], search: true, stacks: true, @@ -56,10 +56,9 @@ const AUTHOR_DISCOVERY_PAGES = 5; const KNOWN_AUTHORS_LIMIT = 10_000; /** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */ const DIFF_FILES_BATCH = 64; -/** - * Line totals cost one hunk read per batch, so a pull request larger than this reports none - * rather than reading every file. - */ +/** Changed-path pages carry no hunks, so they can be read in larger pages. */ +const CHANGES_PAGE_LIMIT = 500; +/** Line totals require hunk reads; omit them for large PRs. */ const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4; const MAX_CHANGE_PAGES = 20; const LINE_STATS_CACHE_SIZE = 256; @@ -116,6 +115,30 @@ const Listed = Schema.Struct({ }); const encodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString }); +const CommitOid = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/u)); +const isCommitOid = Schema.is(CommitOid); +const SourceSnapshot = Schema.Struct({ + version: NonNegativeInt, + headOid: CommitOid, + comparisonBaseOid: CommitOid, +}); +const SourceBlob = Schema.Struct({ + commit: CommitOid, + path: Schema.String, + size: NonNegativeInt, + binary: Schema.Boolean, + content: Schema.NullOr(Schema.String), +}); +const MAX_DIFF_FILE_BYTES = 1024 * 1024; + +class GitCafeDiffSourceUnavailableError extends Schema.TaggedError()( + "GitCafeDiffSourceUnavailableError", + { + path: Schema.String, + reason: Schema.Literals(["oversized", "binary", "unavailable"]), + cause: Schema.optional(Schema.Defect()), + }, +) {} const Snapshot = { version: NonNegativeInt, headOid: TrimmedNonEmptyString, @@ -190,7 +213,7 @@ const withDetails = ( return files.map((file) => byPath.get(file.path) ?? file); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const api = yield* GitCafeApi.GitCafeApi; // GitCafe filters by actor id and has no lookup from a GitHub login to the actor sync made for @@ -271,6 +294,58 @@ export const make = Effect.gen(function* () { Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)), ); + const readDiffSource = Effect.fn("GitCafePullRequestProvider.readDiffSource")(function* ( + input: ProviderRepositoryRef, + revision: string, + filePath: string, + ) { + const operation = "getDiffFileContents"; + const base = yield* repositoryPath(operation, input); + const query = new URLSearchParams({ oid: revision, path: filePath }); + const blob = yield* read(operation, input, { path: `${base}/blob?${query}` }, SourceBlob).pipe( + Effect.catchIf( + (error) => + isApiError(error.cause) && (error.cause.status === 413 || error.cause.status === 501), + (error) => + Effect.fail( + failure( + operation, + "GitCafe cannot provide this source file as bounded text.", + new GitCafeDiffSourceUnavailableError({ + path: filePath, + reason: + isApiError(error.cause) && error.cause.status === 413 + ? "oversized" + : "unavailable", + cause: error, + }), + ), + ), + ), + ); + if (blob.commit !== revision || blob.path !== filePath) + return yield* failure( + operation, + "GitCafe answered with a different source revision or path.", + ); + const reason = + blob.size > MAX_DIFF_FILE_BYTES || + (blob.content !== null && Buffer.byteLength(blob.content, "utf8") > MAX_DIFF_FILE_BYTES) + ? "oversized" + : blob.binary || blob.content?.includes("\0") + ? "binary" + : blob.content === null + ? "unavailable" + : null; + if (reason !== null) + return yield* failure( + operation, + "GitCafe cannot provide this source file as bounded text.", + new GitCafeDiffSourceUnavailableError({ path: filePath, reason }), + ); + return blob.content!; + }); + const readPages = Effect.fn("GitCafePullRequestProvider.readPages")(function* < S extends Schema.Codec, unknown, never, never>, >(operation: string, input: Pick, path: string, schema: S) { @@ -449,6 +524,45 @@ export const make = Effect.gen(function* () { return { ...stack, members }; }); + const readDiffPage = Effect.fn("GitCafePullRequestProvider.readDiffPage")(function* ( + operation: string, + input: Parameters[0], + limit: number, + ) { + if (input.commit !== undefined) + return yield* failure(operation, "GitCafe commit diffs are not supported yet."); + const cursor = + input.cursor === undefined + ? null + : yield* decodeDiffCursor(input.cursor).pipe( + Effect.mapError((cause) => failure(operation, "Invalid GitCafe diff cursor.", cause)), + ); + // A pull without a head yet has nothing to pin; its version still is. + const expected = cursor ?? (yield* readPull(operation, input)); + const version = expected.version; + const path = yield* pullPath(operation, input); + const query = new URLSearchParams({ expectedVersion: String(version), limit: String(limit) }); + if (cursor !== null) query.set("after", cursor.after); + const page = yield* read(operation, input, { path: `${path}/changes?${query}` }, Changes); + if ( + page.version !== version || + (expected.headOid !== null && page.headOid !== expected.headOid) || + (cursor !== null && cursor.baseOid !== page.comparisonBaseOid) + ) + return yield* failure( + operation, + "GitCafe answered from a different revision of the pull request. Refresh the diff.", + ); + if (cursor !== null && page.next !== null && page.next === cursor.after) + return yield* failure(operation, "GitCafe returned a repeated diff page cursor."); + const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; + return { + items: page.items, + revision, + nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + }; + }); + const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* ( input: PullRef, revision: { readonly version: number; readonly headOid: string; readonly baseOid: string }, @@ -532,7 +646,10 @@ export const make = Effect.gen(function* () { let revision: { version: number; headOid: string; baseOid: string } | undefined; let after: string | null = null; for (let page = 0; page < MAX_CHANGE_PAGES; page++) { - const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" }); + const query = new URLSearchParams({ + expectedVersion: String(version), + limit: String(CHANGES_PAGE_LIMIT), + }); if (after !== null) query.set("after", after); const batch: typeof Changes.Type = yield* read( "getChangeRequest", @@ -587,8 +704,7 @@ export const make = Effect.gen(function* () { if (pull.mergeRoute === "unsupported") return yield* failure("merge", "This GitCafe pull request has no supported merge route."); const base = yield* repositoryPath("merge", input); - // GitCafe observes the base only for merges it routes to the provider; a native merge is - // fenced on the target branch's current commit. + // Native merges fence on the current target commit; provider-routed merges supply the base. const baseOid = pull.mergeRoute === "provider" ? pull.observedBaseOid @@ -796,6 +912,19 @@ export const make = Effect.gen(function* () { { concurrency: 5 }, ); const listed = { ...pull, reviewers: reviewers.items, labels: labels.items }; + const checkList = checks === null ? [] : Json.toChecks(checks); + // GitCafe's own counts include runs a re-run replaced, so the newest runs are the state. A + // truncated listing may be missing a failure; the counts never call failing checks passing. + const checksState = + checks !== null && checks.next === null + ? Json.checksStateOf(checkList) + : status.checks.total === 0 + ? null + : status.checks.failing > 0 + ? ("failing" as const) + : status.checks.pending > 0 + ? ("pending" as const) + : ("passing" as const); const permissions = Json.toViewerPermissions(pull); const merging = status.merge.reasons?.some((reason) => MERGE_IN_FLIGHT.has(reason)) ?? false; return { @@ -813,17 +942,8 @@ export const make = Effect.gen(function* () { mergedAt: pull.mergedAt, ...settledMerge(pull), reviewers: Json.toReviewers(listed, input.host), - checks: checks === null ? [] : Json.toChecks(checks), - ...(status.checks.total === 0 - ? {} - : { - checksState: - status.checks.failing > 0 - ? ("failing" as const) - : status.checks.pending > 0 - ? ("pending" as const) - : ("passing" as const), - }), + checks: checkList, + ...(checksState === null ? {} : { checksState }), mergeability: status.merge.conflicts === "conflicting" ? ("conflicting" as const) @@ -890,38 +1010,70 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)), getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) { - if (input.commit !== undefined) - return yield* failure("getDiff", "GitCafe commit diffs are not supported yet."); - const cursor = - input.cursor === undefined - ? null - : yield* decodeDiffCursor(input.cursor).pipe( - Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)), - ); - const version = cursor?.version ?? (yield* readPull("getDiff", input)).version; - const path = yield* pullPath("getDiff", input); - const query = new URLSearchParams({ - expectedVersion: String(version), - limit: String(DIFF_FILES_BATCH), - }); - if (cursor !== null) query.set("after", cursor.after); - const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes); - if ( - page.version !== version || - (cursor !== null && - (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid)) - ) - return yield* failure( - "getDiff", - "GitCafe answered from a different revision of the pull request. Refresh the diff.", - ); - const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid }; - const details = yield* readDiffFiles(input, revision, detailablePaths(page.items)); + const page = yield* readDiffPage("getDiff", input, DIFF_FILES_BATCH); + const details = yield* readDiffFiles(input, page.revision, detailablePaths(page.items)); return { ...Json.toDiff({ items: withDetails(page.items, details) }), - nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }), + nextCursor: page.nextCursor, + }; + }), + getDiffFilePaths: Effect.fn("GitCafePullRequestProvider.getDiffFilePaths")(function* (input) { + const page = yield* readDiffPage("getDiffFilePaths", input, CHANGES_PAGE_LIMIT); + return { + files: page.items.map((file) => ({ + oldPath: file.oldPath ?? file.path, + newPath: file.path, + })), + nextCursor: page.nextCursor, }; }), + getDiffFileContents: Effect.fn("GitCafePullRequestProvider.getDiffFileContents")( + function* (input) { + const operation = "getDiffFileContents"; + if (input.commit !== undefined) + return yield* failure(operation, "GitCafe commit diff contents are not supported yet."); + const pull = yield* readPull(operation, input); + if (!isCommitOid(pull.headOid)) + return yield* failure( + operation, + "GitCafe has not reported an immutable pull request head.", + ); + const path = yield* pullPath(operation, input); + const query = new URLSearchParams({ expectedVersion: String(pull.version), limit: "1" }); + // observedBaseOid is the target tip, not necessarily the diff's merge base. + const snapshot = yield* read( + operation, + input, + { path: `${path}/changes?${query}` }, + SourceSnapshot, + ); + if (snapshot.version !== pull.version || snapshot.headOid !== pull.headOid) + return yield* failure( + operation, + "GitCafe changed the pull request revision. Refresh the diff.", + ); + const sourceRepository = + pull.sourceRepo === null + ? input.repository + : `${pull.sourceRepo.owner}/${pull.sourceRepo.name}`; + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readDiffSource(input, snapshot.comparisonBaseOid, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readDiffSource( + { ...input, repository: sourceRepository }, + snapshot.headOid, + input.newPath, + ), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }, + ), runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) { if (input.stackNumber !== undefined) return yield* runStackAction({ ...input, stackNumber: input.stackNumber }); diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts index 08fee86bc2670..111ffbd1537c3 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.test.ts @@ -242,12 +242,14 @@ describe("GitCafeSourceControlProvider", () => { }; return Effect.gen(function* () { const provider = yield* GitCafeSourceControlProvider.make; - yield* provider.checkoutChangeRequest({ - cwd: "/repo", - reference: "https://git.cafe/team/project/pulls/7", - ...(context === undefined ? {} : { context }), - }); - assert.deepStrictEqual(remotes, [expected]); + for (const route of ["pull", "pulls"]) { + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + reference: `https://git.cafe/team/project/${route}/7`, + ...(context === undefined ? {} : { context }), + }); + } + assert.deepStrictEqual(remotes, [expected, expected]); }).pipe( Effect.provide( Layer.merge(GitCafeApi.layer, NoFiles).pipe( diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index fe624532f74b3..dc3bcf3a1ac0e 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -20,6 +20,7 @@ import { import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; +import { parsePullRequestUrl } from "../shared/definition.ts"; import * as GitCafeApi from "./GitCafeApi.ts"; import * as GitCafeCredentials from "./GitCafeCredentials.ts"; @@ -78,7 +79,7 @@ const cliDiscovery = { const failure = cliFailure(input.stderr) ?? cliFailure(input.stdout); if (failure) return providerAuth({ - // Only a refusal says the login is gone; a forbidden or offline answer says nothing. + // Forbidden/offline responses do not establish that a login is gone. status: failure.code === "AUTHENTICATION_REQUIRED" || failure.status === 401 ? "unauthenticated" @@ -166,8 +167,6 @@ const cloneUrls = (target: Target) => ({ const isSshRemote = (remoteUrl: string | undefined) => remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl)); -const pullUrlPattern = /^https:\/\/([^/]+)\/([^/]+\/[^/]+)\/pulls\/(\d+)\/?(?:[?#].*)?$/u; - export const make = Effect.gen(function* () { const api = yield* GitCafeApi.GitCafeApi; const host = yield* SourceControlHost.SourceControlHost; @@ -242,15 +241,15 @@ export const make = Effect.gen(function* () { readonly context?: SourceControlProvider.SourceControlProviderContext; readonly reference: string; }) { - const match = pullUrlPattern.exec(input.reference.trim()); - const urlHost = match?.[1] ? GitCafeHosts.gitCafeHost(match[1]) : null; + const match = parsePullRequestUrl(input.reference.trim()); + const urlHost = match === null ? null : GitCafeHosts.gitCafeHost(match.host); const target: Target = - urlHost && match?.[2] - ? { host: urlHost, repository: match[2] } + urlHost && match + ? { host: urlHost, repository: match.repository } : yield* resolveTarget("getChangeRequest", input); const number = match ? urlHost - ? match[3] + ? String(match.number) : undefined : /^#?([1-9]\d*)$/u.exec(input.reference.trim())?.[1]; if (number === undefined) @@ -315,7 +314,7 @@ export const make = Effect.gen(function* () { ); for (const pull of page.items) { if (pull.sourceBranch !== branch || items.length >= limit) continue; - // List rows omit fork identity, so only the branch's own rows are read in full. + // List rows omit fork identity; read matching branches in full. const detail = yield* getPull({ cwd: input.cwd, reference: `https://${target.host}/${target.repository}/pulls/${pull.number}`, @@ -452,7 +451,7 @@ export const make = Effect.gen(function* () { ); return repository.defaultBranch; }), - // `cafe` has no forced checkout, so the branch is fetched and switched to with git. + // `cafe` has no forced checkout. checkoutChangeRequest: Effect.fn("GitCafeSourceControlProvider.checkoutChangeRequest")( function* (input) { const pull = yield* getPull(input); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index f3b08b6c647ca..83279771cb4f0 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -336,6 +336,115 @@ describe("toStack", () => { }); describe("toDiff", () => { + it("marks the unterminated 93-byte tunnel-release migration as missing its final newline", () => { + const content = + 'ALTER TABLE "relay_managed_endpoint_allocations" ADD COLUMN "tunnel_released_at" varchar(64);'; + assert.strictEqual(Buffer.byteLength(content), 93); + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql", + status: "added", + hunks: [ + { + oldStart: 1, + oldLines: 0, + newStart: 1, + newLines: 1, + lines: [{ origin: "+", content }], + }, + ], + }, + ], + }), + ); + assert.isTrue(patch.endsWith(`+${content}\n\\ No newline at end of file\n`)); + }); + + it.each(["+", "-", " "])("marks unterminated ordinary %j lines", (origin) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "file.ts", + status: "modified", + hunks: [ + { + oldStart: 1, + oldLines: origin === "+" ? 0 : 1, + newStart: 1, + newLines: origin === "-" ? 0 : 1, + lines: [{ origin, content: "value" }], + }, + ], + }, + ], + }), + ); + assert.isTrue(patch.endsWith(`${origin}value\n\\ No newline at end of file\n`)); + }); + + it.each([ + ["+", ">"], + ["-", "<"], + [" ", "="], + ])("emits one marker for an explicit %s/%s EOF pair", (origin, marker) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "file.ts", + status: "modified", + hunks: [ + { + oldStart: 1, + oldLines: origin === "+" ? 0 : 1, + newStart: 1, + newLines: origin === "-" ? 0 : 1, + lines: [ + { origin, content: "value" }, + { origin: marker, content: "\n" }, + ], + }, + ], + }, + ], + }), + ); + assert.strictEqual(patch.split("\\ No newline at end of file").length - 1, 1); + }); + + it.each([false, true])("preserves rename metadata for changed=%s", (changed) => { + const { patch } = GitCafePullRequestJson.toDiff( + decodeDiff({ + items: [ + { + path: "after.ts", + oldPath: "before.ts", + status: "renamed", + hunks: changed + ? [ + { + oldStart: 1, + oldLines: 1, + newStart: 1, + newLines: 1, + lines: [ + { origin: "-", content: "before\n" }, + { origin: "+", content: "after\n" }, + ], + }, + ] + : [], + }, + ], + }), + ); + assert.include(patch, changed ? "similarity index 0%\n" : "similarity index 100%\n"); + assert.include(patch, "rename from before.ts\nrename to after.ts\n"); + }); + it("converts numeric hunk coordinates into a unified patch", () => { const diff = decodeDiff({ items: [ @@ -429,7 +538,7 @@ describe("toChecks", () => { check("completed", "success", "Build"), check("completed", "cancelled"), check("completed", "action_required"), - check("completed", "timed_out", "Build"), + check("completed", "timed_out", "Lint"), ], next: null, }), @@ -438,7 +547,7 @@ describe("toChecks", () => { checks.map((item) => [item.name, item.status]), [ ["completed-cancelled", "cancelled"], - ["Build", "failure"], + ["Lint", "failure"], ["queued-null", "pending"], ["in_progress-null", "pending"], ["Build", "success"], @@ -446,4 +555,33 @@ describe("toChecks", () => { ], ); }); + + it("keeps only the newest run of a re-run check", () => { + const run = (id: string, conclusion: string, githubApp = "github-actions") => ({ + id, + provider: "github", + githubApp, + name: "Check", + status: "completed", + conclusion, + summary: null, + detailsUrl: null, + }); + const statusOf = (items: ReadonlyArray>) => + GitCafePullRequestJson.toChecks(decodeChecks({ items, next: null })).map( + (check) => check.status, + ); + assert.deepStrictEqual(statusOf([run("99", "failure"), run("100", "success")]), ["success"]); + assert.deepStrictEqual(statusOf([run("101", "failure"), run("100", "success")]), ["failure"]); + // Another app's run of the same name is a separate check, not a re-run. + assert.deepStrictEqual(statusOf([run("100", "failure", "a"), run("101", "success", "b")]), [ + "failure", + "success", + ]); + // Commit statuses carry no run order, so neither is taken for the other's re-run. + assert.deepStrictEqual(statusOf([run("status:2", "failure"), run("status:1", "success")]), [ + "failure", + "success", + ]); + }); }); diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts index a3ba916bd3bd0..cc692637b82fe 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.ts @@ -9,6 +9,7 @@ import { TrimmedNonEmptyString, type PullRequestActor, type PullRequestCheck, + type PullRequestChecksState, type PullRequestComment, type PullRequestReaction, type PullRequestReactionContent, @@ -117,7 +118,7 @@ const GitCafeComment = Schema.Struct({ body: Schema.NullOr(Schema.String), createdAt: IsoDateTime, path: Schema.NullOr(Schema.String), - // The API allows any integer here; only a positive line anchors a review thread. + // Only positive lines anchor review threads; the API allows any integer. line: Schema.NullOr(Schema.Int), side: Schema.NullOr(Schema.Literals(["left", "right"])), commitOid: Schema.NullOr(Schema.String), @@ -234,7 +235,7 @@ const GitCafeDiffFile = Schema.Struct({ isSubmodule: Schema.optional(Schema.Boolean), oldOid: Schema.optional(Schema.String), newOid: Schema.optional(Schema.String), - // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them. + // `/changes` and `/compare` omit hunks; batched reads fill them. hunks: Schema.optional( Schema.Array( Schema.Struct({ @@ -258,6 +259,9 @@ export const GitCafeDiff = Schema.Struct({ export const GitCafeChecks = Schema.Struct({ items: Schema.Array( Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + provider: Schema.optional(Schema.NullOr(Schema.String)), + githubApp: Schema.optional(Schema.NullOr(Schema.String)), name: TrimmedNonEmptyString, status: Schema.Literals(["queued", "in_progress", "completed"]), conclusion: Schema.NullOr(Schema.String), @@ -563,6 +567,10 @@ export function toStack(stack: GitCafeStack, target: RepositoryOnHost): Provider }; } +function isEOFMarker(origin: string | undefined) { + return origin === "=" || origin === ">" || origin === "<"; +} + export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { const chunks: Array = []; const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = []; @@ -578,7 +586,16 @@ export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { if (file.status === "deleted") lines.push(`deleted file mode ${mode}`); if (file.status === "renamed" || file.status === "copied") { const action = file.status === "renamed" ? "rename" : "copy"; + const hasChanges = + (file.hunks?.length ?? 0) > 0 || + (file.additions ?? 0) > 0 || + (file.deletions ?? 0) > 0 || + file.binary || + file.tooLarge || + file.hunksOmitted; + // GitCafe has no similarity score; Pierre needs this header to retain rename metadata. lines.push( + `similarity index ${hasChanges ? 0 : 100}%`, `${action} from ${quoteGitPatchPath(oldPath)}`, `${action} to ${quoteGitPatchPath(file.path)}`, ); @@ -603,10 +620,19 @@ export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice { if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`); for (const hunk of hunks) { lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); - for (const line of hunk.lines) { - // GitCafe marks a missing final newline with libgit2's own origins. - if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file"); - else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + for (const [index, line] of hunk.lines.entries()) { + if (isEOFMarker(line.origin)) { + lines.push("\\ No newline at end of file"); + } else { + lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`); + if ( + ["+", "-", " "].includes(line.origin) && + !line.content.endsWith("\n") && + !isEOFMarker(hunk.lines[index + 1]?.origin) + ) { + lines.push("\\ No newline at end of file"); + } + } } } } @@ -638,9 +664,40 @@ function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequ } } +type GitCafeCheck = (typeof GitCafeChecks.Type.items)[number]; + +/** A check run's id, which grows with each run. Commit statuses (`status:…`) carry no such order. */ +function runOrder(check: GitCafeCheck): bigint | null { + return check.id != null && /^\d+$/u.test(check.id) ? BigInt(check.id) : null; +} + +/** + * GitCafe lists every run of a commit's checks, so a re-run arrives beside the run it replaced. + * The newest run is the check's state; an older failure is history, not a failing check. Runs whose + * order cannot be told apart are all kept, so no failure is hidden. Two reporters may share a check + * name, so a run only replaces runs from the same provider and GitHub app. + */ export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray { + const kept: Array = []; + const newestByKey = new Map(); + for (const check of checks.items) { + const order = runOrder(check); + if (order === null) { + kept.push(check); + continue; + } + const key = JSON.stringify([check.provider ?? null, check.githubApp ?? null, check.name]); + const newest = newestByKey.get(key); + if (newest === undefined) { + newestByKey.set(key, { index: kept.length, order }); + kept.push(check); + } else if (order > newest.order) { + kept[newest.index] = check; + newest.order = order; + } + } return sortChecks( - checks.items.map((check) => ({ + kept.map((check) => ({ name: check.name, status: checkStatus(check), description: check.summary, @@ -648,3 +705,12 @@ export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray, +): PullRequestChecksState | null { + const statuses = new Set(checks.map((check) => check.status)); + if (statuses.has("failure") || statuses.has("cancelled")) return "failing"; + if (statuses.has("pending") || statuses.has("action-required")) return "pending"; + return statuses.has("success") ? "passing" : null; +} diff --git a/packages/source-control-gitcafe/src/shared/definition.test.ts b/packages/source-control-gitcafe/src/shared/definition.test.ts index 5e99537fd96d0..e69ffd364018c 100644 --- a/packages/source-control-gitcafe/src/shared/definition.test.ts +++ b/packages/source-control-gitcafe/src/shared/definition.test.ts @@ -1,11 +1,45 @@ import { recognizeRemoteWith } from "@t3tools/source-control-core/shared/definition"; import { describe, expect, it } from "vite-plus/test"; -import { definition } from "./definition.ts"; +import { canonicalizePullRequestUrl, definition, parsePullRequestUrl } from "./definition.ts"; const parse = (url: string) => definition.parseChangeRequestUrl(new URL(url)); describe("GitCafe change request URLs", () => { + it.each(["git.cafe", "staging.git.cafe"])("normalizes links for storage on %s", (host) => { + for (const repository of ["web", "pull", "pulls"]) { + for (const route of ["pull", "pulls"]) { + const url = `https://${host}/Acme/${repository}/${route}/42/files?w=1#diff`; + expect(parsePullRequestUrl(url)).toMatchObject({ + host, + repository: `acme/${repository}`, + number: 42, + }); + expect(definition.isChangeRequestReference(url)).toBe(true); + expect(canonicalizePullRequestUrl(url)).toBe(`https://${host}/acme/${repository}/pulls/42`); + } + } + }); + + it("preserves other URLs and rejects invalid GitCafe references", () => { + for (const url of [ + "https://github.com/team/repo/pull/7", + "https://forge.example/team/repo/pulls/7", + "https://not.git.cafe/team/repo/pull/7", + "https://git.cafe.evil.test/team/repo/pull/7", + "https://git.cafe/tfs/collection/project/_git/repo/pullrequest/7", + "https://git.cafe/team/repo/pull/0", + "https://git.cafe/team/repo/pulls/9007199254740992", + "https://staging.git.cafe/team/repo/pull/7suffix", + "ftp://git.cafe/team/repo/pull/7", + "not a url", + ]) { + expect(parsePullRequestUrl(url), url).toBeNull(); + expect(definition.isChangeRequestReference(url), url).toBe(false); + expect(canonicalizePullRequestUrl(url), url).toBe(url); + } + }); + it("reads its canonical /pull/ path and the older /pulls/ path on its own hosts", () => { for (const path of ["pull", "pulls"]) { expect(parse(`https://git.cafe/PingDotGG/T3Code/${path}/23897`), path).toEqual({ diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index 7984dced627c4..dc352be748dc2 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -22,6 +22,12 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); /** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; +function parseChangeRequestUrl(url: URL) { + return GITCAFE_HOSTS.has(url.hostname.toLowerCase()) + ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) + : null; +} + const KIND = SourceControlProviderKind.make("gitcafe"); export const settings = makeProviderSettingsSchema({ @@ -50,7 +56,7 @@ export const definition = defineSourceControl({ plural: "pull requests", }, repositoryPathHint: "owner/repo", - publicHost: null, + publicHost: "git.cafe", publishDescription: "git.cafe", publishHost: () => "git.cafe", newRepositoryOwner: (account) => (account ? { owner: account } : null), @@ -61,10 +67,7 @@ export const definition = defineSourceControl({ : `https://${host}/${repository}/pulls/${number}`, // `/pull/` and `/pulls/` are GitHub's and Forgejo's shapes too, so only GitCafe's own hosts // claim them. - parseChangeRequestUrl: (url) => - GITCAFE_HOSTS.has(url.hostname.toLowerCase()) - ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) - : null, + parseChangeRequestUrl, changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", @@ -79,7 +82,7 @@ export const definition = defineSourceControl({ authorProfileUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: () => null, - isChangeRequestReference: () => false, + isChangeRequestReference: (url) => parsePullRequestUrl(url) !== null, checkoutChangeRequestHost: () => null, recognizeRemote: ({ hostname }) => GITCAFE_HOSTS.has(hostname) @@ -91,3 +94,16 @@ export const definition = defineSourceControl({ isChangeRequestOnProjectHost(projectChangeRequestHost(identity, KIND), identity, link), settings, }); + +export function parsePullRequestUrl(value: string) { + try { + return parseChangeRequestUrl(new URL(value)); + } catch { + return null; + } +} + +export function canonicalizePullRequestUrl(value: string): string { + const link = parsePullRequestUrl(value); + return link === null ? value : definition.changeRequestUrl(link); +} diff --git a/packages/source-control-github/graphql.config.ts b/packages/source-control-github/graphql.config.ts index f2db555177ee7..7244f082e522e 100644 --- a/packages/source-control-github/graphql.config.ts +++ b/packages/source-control-github/graphql.config.ts @@ -1,16 +1,13 @@ import { defineConfig } from "@effect/graphql-generator/Config"; -// `schema/github.graphql` is github.com's public schema -// (https://docs.github.com/public/fpt/schema.docs.graphql). Enterprise Server lacks some of its -// fields, such as pull request stacks, so a document asks for those under `@include`. +// Public schema: https://docs.github.com/public/fpt/schema.docs.graphql. Gate Enterprise-missing fields with @include. export default defineConfig({ schema: "./schema/github.graphql", documents: ["src/**/*.graphql"], scalars: { GitObjectID: "effect/Schema#String", - // A fully qualified ref name such as `refs/heads/main`. GitRefname: "effect/Schema#String", - // Kept as GitHub sends it: callers compare these strings with other reads of the same field. + // Preserve GitHub's strings for comparison with other reads. DateTime: "effect/Schema#String", }, }); diff --git a/packages/source-control-github/scripts/measure-pr-preview.ts b/packages/source-control-github/scripts/measure-pr-preview.ts index 448984f5ce8b4..d1c97074e16a2 100644 --- a/packages/source-control-github/scripts/measure-pr-preview.ts +++ b/packages/source-control-github/scripts/measure-pr-preview.ts @@ -1,9 +1,7 @@ // Run with: node packages/source-control-github/scripts/measure-pr-preview.ts owner/repo 123 456 -// Numbers form a session with shared repository-permission caches. Browser and -// service caches are excluded. Uses real GitHub reads, without a server or database. -// Requests are counted at the HTTP client. A GraphQL request's cost is how far it moved GitHub's -// `x-ratelimit-used` for the `graphql` resource, which is approximate when anything else spends -// the same token's quota while this runs. +// Real GitHub reads share repository-permission caches, excluding browser and service caches. +// HTTP client request counts are exact; GraphQL cost uses changes in `x-ratelimit-used`, +// which are approximate when other clients spend the same token's quota. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; @@ -29,7 +27,6 @@ if (!repository || numbers.length === 0 || numbers.some((number) => !/^\d+$/.tes type Read = { graphqlRequests: number; restRequests: number; cost: number }; const reads: Read[] = []; -/** The `graphql` quota's use so far, and the window it counts in. */ let graphqlUsed: { used: number; reset: string } | null = null; const decodeRateLimit = Schema.decodeUnknownSync( Schema.fromJsonString( @@ -41,10 +38,7 @@ const decodeRateLimit = Schema.decodeUnknownSync( ), ); -/** - * What one GraphQL answer spent: how far it moved `used` past the highest value seen, so requests - * answered out of order still add up to the window's total. A new window starts from zero. - */ +// Count only increases in `used` so out-of-order answers do not double-count quota. function graphqlCost(headers: Readonly>): number { const used = Number(headers["x-ratelimit-used"]); const reset = headers["x-ratelimit-reset"] ?? ""; @@ -54,7 +48,6 @@ function graphqlCost(headers: Readonly>): num return Math.max(0, used - previous); } -/** The HTTP client the GitHub transports send through, recording each request as it is answered. */ const measuredHttp = Layer.effect( HttpClient.HttpClient, Effect.gen(function* () { @@ -119,7 +112,7 @@ for (const mode of ["detail", "preview"] as const) { Effect.scoped( Effect.gen(function* () { const provider = yield* GitHubPullRequestProvider.make; - // Where the `graphql` quota stands before the first read. `rate_limit` is free. + // `rate_limit` reads do not spend GraphQL quota. const api = yield* GitHubRest.GitHubRest; const limits = decodeRateLimit( (yield* api.rest({ host: "github.com", operation: "rateLimit", path: "rate_limit" })) diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index cf124eac2cf78..63c7ef29ddc43 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -96,16 +96,11 @@ function normalizeHost(host: string): string { return host.trim().toLowerCase(); } -/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ function isGitHubDotCom(host: string): boolean { return host === "github.com" || host.endsWith(".ghe.com"); } -/** - * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to - * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL - * picks the host and a hostile one must not receive an enterprise token. - */ +/** Restrict GH_ENTERPRISE_TOKEN to GH_HOST to prevent remote URLs from leaking it. */ export function environmentToken( host: string, env: Readonly>, @@ -122,7 +117,7 @@ export function environmentToken( return null; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const process = sourceControlHost.process; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 23cadae857751..4334bf2f68de8 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -89,7 +89,6 @@ type ApiCall = readonly kind: "graphql"; readonly authorization: string | undefined; readonly partial?: boolean; - /** The generated operation's name, which the routes below match on. */ readonly operationName?: string; readonly host: string; readonly operation: string; @@ -108,7 +107,6 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => Effect.succeed(output('{"data":{}}')), ); -/** The REST transport, answered from the mocks above. */ const mockApi = Layer.succeed( GitHubRest.GitHubRest, GitHubRest.GitHubRest.of({ @@ -117,11 +115,6 @@ const mockApi = Layer.succeed( }), ); -/** - * The HTTP side of the generated GraphQL reads, answered from the same mocks: a request to a - * host's GraphQL endpoint is recorded as a `graphql` call, carrying the `partial` flag the batches - * read with, and answered with the mocked body, status and headers. - */ const mockHttp = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -145,7 +138,6 @@ const mockHttp = Layer.succeed( variables: sent.variables, partial: true, }; - // A mocked failure with a status is what GitHub would have answered with it. const answer = yield* ( sent.query.startsWith("query PullRequestStackMembership(") ? mockedStackMemberships @@ -157,8 +149,7 @@ const mockHttp = Layer.succeed( }), Effect.orDie, ); - // A mutation mocked as `{}` is one whose answer the test does not read. GitHub always - // answers a mutation that ran with `data`, so it is sent as the null payload it selects. + // Empty mutation mocks still need the selected GraphQL root field. const body = answer.body === "{}" && sent.query.startsWith("mutation ") ? encodeJson(mutationAnswer(sent.query)) @@ -213,7 +204,6 @@ function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { }; } -/** What every listing row answers with, which a fixture overrides only where it matters. */ const rowDefaults = { author: null, state: "OPEN", @@ -227,13 +217,8 @@ const rowDefaults = { commits: { nodes: [] }, }; -/** A connection's page info with no next page, as GitHub sends it: the cursor is always there. */ const lastPage = { hasNextPage: false, endCursor: null }; -/** - * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most - * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. - */ function rows( count: number, firstNumber: number, @@ -401,7 +386,6 @@ function threadCommentsPage( }); } -/** What GitHub answers for a pull request it will not serve a whole diff for. */ const diffRefused = new GitHubRequestPolicy.GitHubApiResponseError({ host: "github.com", operation: "getPullRequestDiff", @@ -472,17 +456,12 @@ function searchPage(nodes: ReadonlyArray, hasNextPage = false) { const searchQueryOfCall = searchOfCall; -/** Whether a request is the GraphQL operation `needle` names, or its document or path holds it. */ function matches(call: ApiCall, needle: string): boolean { return call.kind === "graphql" ? call.operationName === needle || call.query.includes(needle) : call.path.includes(needle); } -/** - * Answers every request with the body of the first route whose needle it matches, and any - * unrouted mutation with the empty payload each one selects, which nothing reads back. - */ function route(...routes: ReadonlyArray): void { mockedExecute.mockImplementation((call) => { const match = routes.find(([needle]) => matches(call, needle)); @@ -498,30 +477,24 @@ function route(...routes: ReadonlyArray>> { return mockedExecute.mock.calls.flatMap(([call]) => call.kind === "graphql" && matches(call, needle) ? [call.variables ?? {}] : [], ); } -/** Every REST request whose path contains `needle`, in order. */ function restCallsTo(needle: string): ReadonlyArray { return mockedExecute.mock.calls.flatMap(([call]) => call.kind === "rest" && call.path.includes(needle) ? [call] : [], ); } -/** - * The check that a client-given node hangs off the pull request it names. Routed before the node - * id lookup, whose document it contains. - */ +/** Route before the node-ID lookup, whose document is contained in this scope query. */ const SUBJECT_SCOPE_QUERY = "PullRequestSubject"; const subjectScope = (subjectId: string, pullRequestId: string) => ({ data: { @@ -530,7 +503,6 @@ const subjectScope = (subjectId: string, pullRequestId: string) => ({ }, }); -/** A check run as the `CheckContext` fragment answers it, with `fields` over the defaults. */ const checkRun = (fields: Record) => ({ __typename: "CheckRun", name: "", @@ -543,7 +515,6 @@ const checkRun = (fields: Record) => ({ ...fields, }); -/** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ const checkContextsPage = ( nodes: ReadonlyArray>, endCursor: string | null, @@ -624,7 +595,7 @@ function workflowApprovalRoutes( ); } -/** The pull request node id lookup, which the layer caches for every test after the first. */ +/** Node IDs stay cached after the first test. */ const NODE_ID_QUERY = "PullRequestNodeId"; const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); @@ -983,7 +954,6 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(Result.isFailure(seven!) ? seven.failure._tag : null).toBe("GitHubApiNotFoundError"); expect(Result.isSuccess(eight!) ? eight.success.headBranch : null).toBe("feat/eight"); - // One request answered both; nothing was read again on its own. expect(mockedExecute).toHaveBeenCalledOnce(); expect(callAt(0)).toMatchObject({ kind: "graphql", partial: true }); }), @@ -1463,8 +1433,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: index + 1, })); mockedExecute.mockImplementation((call) => - // Every chunk finds only its first pull request, so a row GitHub had nothing for is - // dropped rather than shown as a change of no size. Effect.succeed( output( encodeJson({ @@ -2236,8 +2204,6 @@ layer("GitHubPullRequestApi.layer", (it) => { action: "approve-workflows", }); - // Open pull requests on this head branch, and runs scoped to this exact head. - // The first page sends no cursor at all, which reads from the start. expect(variablesOf("PullRequestHeads")[0]).toEqual({ owner: "acme", name: "web", @@ -2978,8 +2944,6 @@ layer("GitHubPullRequestApi.layer", (it) => { encodeJson({ data: { repository: { pullRequest: { id: "PR_thisOne" } }, - // A comment on pull request #99 of a different repository, named as though it - // belonged to #7 here. node: { __typename: "IssueComment", id: "IC_99", @@ -3390,7 +3354,6 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - // The first page asks from the beginning; the next carries on from where it stopped. expect(varsAt(0)["after"]).toBeNull(); expect(varsAt(1)["after"]).toBe("Y3Vyc29yOjE"); expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 741f238f4b446..fd530037666a7 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -364,7 +364,6 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000; const DIFF_FILES_PAGE_SIZE = 100; const FILES_VIEWED_MAX_PAGES = 5; -/** Files cleared or restored per request; a reader rarely ticks more than a page of them at once. */ const FILES_VIEWED_PER_REQUEST = 100; export const NODE_ID_CACHE_CAPACITY = 128; @@ -376,7 +375,7 @@ const CHECK_CONTEXT_PAGES = 10; export interface GitHubPullRequestListBatch { readonly items: ReadonlyArray; readonly truncated: boolean; - /** False for a page GitHub would not search, which came back in `gh`'s own order instead. */ + /** False for unsearchable pages returned in `gh` order. */ readonly continues: boolean; } @@ -387,16 +386,9 @@ export interface GitHubPullRequestStat { readonly deletions: number; } -/** - * Aliased lookups per request. Measured over a hundred rows: one request carrying all hundred - * takes ~5.2s, four of twenty-five in parallel ~2.1s. `GitHubRequestPolicy` bounds how many run at once. - */ +/** Four parallel batches of 25 took ~2.1s; one batch of 100 took ~5.2s. */ const STAT_ALIASES_PER_REQUEST = 25; -/** - * 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. - */ +// Cache checks stagger reads across scheduler ticks; allow time to batch. const SUMMARY_BATCH_WINDOW = "10 millis"; export interface GitHubPullRequestSearchBatch { @@ -406,7 +398,7 @@ export interface GitHubPullRequestSearchBatch { export interface GitHubPullRequestDiffSlice { readonly patch: string; - /** Files in this slice had their hunks withheld, as opposed to there being more slices. */ + /** Hunks were withheld within this slice. */ readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats?: ReadonlyArray; @@ -486,10 +478,7 @@ export class GitHubPullRequestApi extends Context.Service< readonly host: string; readonly number: number; }) => Effect.Effect; - /** - * Null when GitHub gave no answer for this pull request, so the watch reads - * it in full instead. - */ + /** Null triggers a full watch read when GitHub gives no answer. */ readonly getPullRequestWatchFingerprint: (input: { readonly cwd: string; readonly repository: string; @@ -763,11 +752,7 @@ function parseRepositorySelector(value: string): { return { name: parts.at(-1) ?? "", owner: parts.at(-2) ?? "" }; } -/** - * The cursor arrives from the reader as a string and goes straight into a request path, so - * it is parsed rather than trusted; the length bound keeps a page number out of exponential - * notation. - */ +/** Bound decimal length to prevent exponential notation in request paths. */ function diffCursorPage(cursor: string): number | null { return /^[1-9][0-9]{0,6}$/.test(cursor) ? Number(cursor) : null; } @@ -776,11 +761,7 @@ function isCommitSha(value: string): boolean { return /^[0-9a-f]{7,64}$/i.test(value); } -/** - * Quoting is the whole defence: outside quotes GitHub reads `is:merged` as a qualifier and - * `label:x` as another, so text typed into a search box could widen the very listing it is - * meant to narrow — inside them it is only text. - */ +/** Quote user text so GitHub cannot interpret it as qualifiers that widen the listing. */ function searchPhrase(query: string): string { return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } @@ -792,11 +773,7 @@ const REVIEW_QUALIFIERS = { none: "none", } as const; -/** - * Values a reader typed are quoted, and the one character that could end the quoted value early - * is dropped rather than escaped: no GitHub label or login holds a double quote, so there is - * nothing to preserve and everything to lose. - */ +/** Labels/logins cannot contain quotes; strip them to prevent qualifier injection. */ function qualifierValue(value: string): string { return `"${value.replaceAll('"', "").trim()}"`; } @@ -807,7 +784,7 @@ function filterQualifiers( ): ReadonlyArray { if (filters === undefined) return []; return [ - // One qualifier per group, its names joined by commas — GitHub's own OR. + // Commas within a qualifier mean OR to GitHub. ...(filters.labels ?? []).flatMap((group) => group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], ), @@ -868,11 +845,7 @@ function matchesUnsortedListing( const SEARCH_REPOSITORY = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; -/** - * A name is written into the query as itself, and a name holding a space could otherwise end - * the `repo:` qualifier and start a qualifier of its own — so an unaddressable one refuses the - * whole read rather than being escaped into something GitHub might still read. - */ +/** Unquoted repo: values must not inject qualifiers. */ function searchQuery(input: { readonly repositories: ReadonlyArray; readonly state: PullRequestListState; @@ -901,7 +874,6 @@ function searchQuery(input: { ].join(" "); } -/** The two revisions a file is expanded between: a pull request's base and head, or a commit's parent and itself. */ const decodeRevisionRefs = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -919,7 +891,6 @@ const GRAPHQL_MERGE_METHODS = { rebase: "REBASE", } as const satisfies Record; -/** Every GraphQL read and write this module makes, through GitHub's auth and rate limits. */ const PullRequestGraphQLGroup = GraphQLGroup.merge( PullRequestBatchesGraphQLGroup, GraphQLGroup.make(LenientPullRequestSearch, LenientPullRequestList), @@ -936,7 +907,7 @@ interface RestReadInput { readonly decode: (raw: string) => Result.Result; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; const policy = yield* GitHubRequestPolicy.GitHubRequestPolicy; @@ -1050,11 +1021,6 @@ export const make = Effect.gen(function* () { const readError = (cwd: string, operation: string, cause: unknown) => new GitHubPullRequestReadError({ cwd, operation, cause }); - /** - * Runs one generated GraphQL call against `host`, reported as `operation`. GitHub's `errors` - * fail it as a not found, or a response error with GitHub's messages; an answer that does not - * decode is a read error against `operation`. - */ const graphql = (input: { readonly cwd: string; @@ -1085,7 +1051,6 @@ export const make = Effect.gen(function* () { ), ); - /** Fails a read GitHub answered with nothing for the pull request it was asked about. */ const found = (input: { readonly host: string; readonly operation: string }) => (value: A | null): Effect.Effect => @@ -1139,13 +1104,7 @@ export const make = Effect.gen(function* () { return { pages, truncated: repeated || (last !== undefined && options.hasNextPage(last)) }; }); - /** - * The pull request's own node id, which is what a mutation against the pull request itself is - * addressed by: a reaction on its description, or a rewrite of its words. - * - * A pull request keeps its node id for life, so it is remembered rather than re-read: a reader - * ticking files viewed would otherwise pay a GraphQL round trip per press. - */ + // Immutable PR node IDs can be cached across viewed-file toggles. const nodeIds = new Map(); const pullRequestNodeId = (input: { @@ -1179,11 +1138,7 @@ export const make = Effect.gen(function* () { ); }; - /** - * A subject id is trusted to be whatever node it names, and that node can hang off any pull - * request on the host — so the mutation itself would write wherever the id actually belongs, - * not wherever the request says it does, unless this confirms the two agree first. - */ + /** Mutations follow node IDs; reject IDs from other PRs. */ const subjectBelongsToPullRequest = (input: { readonly cwd: string; readonly repository: string; @@ -1198,7 +1153,6 @@ export const make = Effect.gen(function* () { .pipe(graphql({ ...input, allowReserve: true }), Effect.map(isSubjectOfPullRequest)); }; - /** Decodes an answer, reporting a failure against the read that made it. */ const decodeWith = ( cwd: string, operation: string, @@ -1287,8 +1241,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.flatMap((response) => { - // Checked before decoding: a byte-truncated response is a JSON prefix, which would - // fail to parse. + // Byte-truncated responses are unparseable JSON prefixes. if (response.truncated) { return Effect.fail( readError( @@ -1306,8 +1259,7 @@ export const make = Effect.gen(function* () { ); }), Effect.map((files) => { - // Counted before decoding, so a page whose files all failed to decode still moves on - // rather than pointing the reader back at the page it just read. + // Use raw row counts so malformed files cannot prematurely end pagination. const morePages = files.rawCount >= DIFF_FILES_PAGE_SIZE; return { patch: files.patch, @@ -1398,7 +1350,7 @@ export const make = Effect.gen(function* () { return { oldContents, newContents }; }); - /** `isRequired` is asked for on github.com only: an older Enterprise server may not know it. */ + /** Older Enterprise servers may lack `isRequired`. */ const asksRequired = (host: string) => host.toLowerCase() === "github.com"; /** @@ -1548,8 +1500,7 @@ export const make = Effect.gen(function* () { const known = yield* KnownWorkflowRuns; if (known !== null && known.headSha === input.headSha) { return known.runs.flatMap((run) => - // A run waiting on a maintainer reports `completed` with an `action_required` - // conclusion; the `status=action_required` query matches either. + // Maintainer-gated runs can report completed/action_required. (run.conclusion === "action_required" || run.status === "action_required") && run.head_branch === input.headBranch ? [ @@ -1644,7 +1595,6 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); - /** The mutations that only need the pull request's node id. */ const simpleActionMutations = { "disable-auto-merge": (pullRequestId: string) => client.DisablePullRequestAutoMerge({ pullRequestId }).pipe(Effect.asVoid), @@ -1658,12 +1608,7 @@ export const make = Effect.gen(function* () { client.ReopenPullRequest({ pullRequestId }).pipe(Effect.asVoid), } satisfies Partial>; - /** - * Runs batched lookups for one read on `host`. The resolver groups them by host and credential. - * GitHub saying one pull request does not exist fails that lookup as not found; any other client - * failure is a read error against `operation`, and a request policy failure (a rate limit, a - * refused credential) passes through as itself. - */ + /** Preserve policy errors; map not-found per lookup and other failures to read errors. */ const batchedRead = ( input: { readonly cwd: string; readonly host: string; readonly operation: string }, effect: Effect.Effect, @@ -1691,12 +1636,7 @@ export const make = Effect.gen(function* () { ), ); - /** - * 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 read - * apiece is most of what it spends. A pull request GitHub cannot find fails on its own; the - * rest of its batch still answers. - */ + // Batch by host/credential; isolate missing PRs. const summaryResolver = GraphQLResolver.aliased(client.PullRequestSummary, { batchKey: GitHubGraphQlClient.batchKey, }).pipe( @@ -1733,8 +1673,7 @@ export const make = Effect.gen(function* () { ), ); - // Every watched pull request on a host in one read per pass. A pull request the batch has no - // answer for gets null, and its watch reads it in full; a failed request fails every entry. + // A missing fingerprint triggers a full watch read; a failed request fails the whole batch. const watchFingerprintResolver = GraphQLResolver.aliased(client.PullRequestWatchFingerprint, { batchKey: GitHubGraphQlClient.batchKey, }).pipe( @@ -1755,16 +1694,13 @@ export const make = Effect.gen(function* () { ); }; - // Rows already on a page, enriched with what the listing query leaves out. They are many, so - // their lookups share aliased requests of twenty-five, four at a time. const statsResolver = GraphQLResolver.aliased(client.PullRequestStats, { batchKey: GitHubGraphQlClient.batchKey, }).pipe(RequestResolver.batchN(STAT_ALIASES_PER_REQUEST)); const stackMembershipResolver = GraphQLResolver.aliased(client.PullRequestStackMembership, { batchKey: GitHubGraphQlClient.batchKey, }).pipe(RequestResolver.batchN(STAT_ALIASES_PER_REQUEST)); - // A burst of ticks is one request, each file under its own alias. GitHub has no bulk form of - // `markFileAsViewed`/`unmarkFileAsViewed`, which each take a single path. + // GitHub has no bulk viewed-file mutation; aliases combine individual paths into one request. const setFileViewedResolver = GraphQLResolver.aliased(client.SetFileViewed, { batchKey: GitHubGraphQlClient.batchKey, }).pipe(RequestResolver.batchN(FILES_VIEWED_PER_REQUEST)); @@ -1842,7 +1778,6 @@ export const make = Effect.gen(function* () { (continues ? client .PullRequestSearch({ - // The reader's own words are in the query. q: query, first: pageRows(rows), after, @@ -1862,8 +1797,6 @@ export const make = Effect.gen(function* () { stats: true, }) .pipe( - // A repository the viewer cannot see answers as an empty page, which is what - // `gh pr list` printed for it too. Effect.map((result) => toPage(toPullRequestRows(result.repository?.pullRequests)), ), @@ -1885,8 +1818,7 @@ export const make = Effect.gen(function* () { } return Effect.succeed({ items: items.slice(0, input.limit), - // One row over the page size is the probe for a next page, and it is - // counted before decoding: a skipped malformed row must not end paging. + // Probe one raw row past the limit; malformed rows must not prematurely end paging. truncated: cutShort || (continues @@ -1896,9 +1828,7 @@ export const make = Effect.gen(function* () { }); }), ); - // GitHub does not index every repository for search, and one it will not search answers - // with no rows rather than with an error — so an empty listing is read again from the - // repository's own list. + // Unindexed repositories silently return no search rows; fall back to the repository listing. const hasQuery = (input.query?.trim().length ?? 0) > 0; return read(true).pipe( Effect.filterOrElse( @@ -1906,7 +1836,6 @@ export const make = Effect.gen(function* () { () => read(false), ), Effect.flatMap((batch) => { - // Match the search query's host support, and enrich only rows that survived paging. if (!stacks || batch.items.length === 0) return Effect.succeed(batch); return Effect.forEach( batch.items, @@ -1922,7 +1851,6 @@ export const make = Effect.gen(function* () { const stack = pullRequest == null ? undefined : toStackMembership(pullRequest); return stack === undefined ? item : { ...item, stack }; }), - // Optional badges must not take down a listing that already read successfully. Effect.catch((error) => Effect.logWarning("Pull request stack membership enrichment failed", { operation: "listPullRequestStackMemberships", @@ -1966,7 +1894,6 @@ export const make = Effect.gen(function* () { graphql({ ...input, operation: "searchPullRequests" }), Effect.map(({ search }) => { const page = toPullRequestRows(search); - // A search row names its repository; one that does not cannot be placed on a page. const items = page.rows.flatMap(({ item, repository }) => repository === null ? [] : [{ ...item, repository }], ); @@ -1990,8 +1917,6 @@ export const make = Effect.gen(function* () { operation: "listPullRequestStats", }); } - // A repository or pull request GitHub answered nothing for is left out, which leaves the - // row with whatever it already had. const stats = yield* Effect.forEach( lookups, ({ changeRequest, lookup }) => @@ -2052,8 +1977,7 @@ export const make = Effect.gen(function* () { decode: (raw) => decodePullRequestStacksJson(`[${raw}]`), }); }), - // Hosts without the stacks preview return 404. Other failures must preserve the - // previously synced stack and let the caller retry. + // 404 means stacks are unsupported; other failures must preserve synced stacks for a retry. Effect.catchTags({ GitHubApiNotFoundError: () => Effect.succeed(null), }), @@ -2071,7 +1995,6 @@ export const make = Effect.gen(function* () { let withComments = true; let withReviews = true; const target = { ...input, operation: "getPullRequestActivity" }; - // Both remark lists page on their own; a page asks only for the ones with more to give. for (let page = 0; page < REVIEW_THREAD_PAGES && (withComments || withReviews); page++) { const read: GitHubPullRequestActivityPage = yield* client .PullRequestActivity({ @@ -2147,8 +2070,7 @@ export const make = Effect.gen(function* () { ? filesPage(1) : Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), ), - // GitHub answers 406 rather than a diff past 300 changed files, so the patch is read - // from the files API instead, a page per call. + // Above 300 changed files, GitHub returns 406; use the paged files API. Effect.catchTags({ GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), }), @@ -2456,7 +2378,6 @@ export const make = Effect.gen(function* () { reason: "source-unavailable", }); } - // Already gone, which is what was asked for. if (repository.ref === null) return; if ( repository.defaultBranchRef === null || @@ -2597,8 +2518,7 @@ export const make = Effect.gen(function* () { } return Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - // Read fresh rather than from the node id cache: merging and updating act on the head - // as it stands now, and the merge queue decides which mutation a merge is. + // Fresh head and merge-queue state determine the mutation's revision and route. const target = { ...input, operation: "runPullRequestAction" }; const state = yield* client .PullRequestActionState({ @@ -2614,7 +2534,6 @@ export const make = Effect.gen(function* () { ); if (action === "update-branch") { if (state.baseRef?.compare?.behindBy === 0) return; - // GitHub updates with a merge commit unless asked to rebase, which is its own default. return yield* client .UpdatePullRequestBranch({ pullRequestId: state.id, @@ -2760,8 +2679,7 @@ export const make = Effect.gen(function* () { const target = { ...input, operation: "setPullRequestFilesViewed" }; return pullRequestNodeId(target).pipe( Effect.flatMap((pullRequestId) => - // One aliased document for the burst: top-level mutation fields run in write order, - // so the last word about a path is the one that sticks. + // Top-level mutation fields run in order, so the last toggle for a path wins. Effect.forEach( input.files, (file) => @@ -2821,8 +2739,6 @@ export const make = Effect.gen(function* () { client .UpdatePullRequest({ pullRequestId, - // A field the caller did not name is left out of the request entirely, so GitHub - // keeps the words that are there rather than being asked for an empty one. ...(input.title === undefined ? {} : { title: input.title }), ...(input.body === undefined ? {} : { body: input.body }), }) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index 1263f07f753c5..c0c0ac2f1b0be 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -754,8 +754,6 @@ describe("getChangeRequestActivity dismissed reviews", () => { }); it.effect("fills a marker-only dismissed review with the timeline's reason", () => - // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — - // an empty-string check misses them and the card opens onto nothing. readActivity.pipe( Effect.map((activity) => { expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 17bf91e83777c..0071e0ca67f53 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -90,15 +90,11 @@ export function gitHubViewerPermissions( ] as const) : []), ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), - // Whether this viewer may update the branch is GitHub's own answer, read with the - // comparison; without it the action is offered to nobody rather than to everybody. ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), ], comment: true, resolve: access.canWrite || access.didAuthor, - // Anyone may review a pull request they can see, except their own: GitHub refuses an author's - // approval and their request for changes ("Can not approve your own pull request"), and - // leaves them commenting, which is what an author has to say about their own change anyway. + // Authors may comment on their own PRs, but GitHub rejects their approvals and change requests. verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), @@ -130,10 +126,6 @@ function gitHubProviderFailure( } } -/** - * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are - * applied here by login. - */ function withAvatar( actor: PullRequestActor | null, avatarsByLogin: ReadonlyMap, @@ -182,10 +174,7 @@ function withWorkflowApprovals( ]; } -/** - * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which - * names no page, and a guessed URL that 404s is worse than the initials it would replace. - */ +/** App logins have no profile page; guessed avatar URLs 404. */ export function loginAvatarUrl(login: string, host: string): string | null { return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; } @@ -497,7 +486,6 @@ export const make = Effect.gen(function* () { Effect.all( [ cli.getPullRequestActivity(input), - // Line comments live on review threads, which `gh pr view --json` cannot reach. cli.listReviewThreadComments(input).pipe( Effect.orElseSucceed(() => ({ comments: [], @@ -552,10 +540,7 @@ export const make = Effect.gen(function* () { comments: [...pullRequest.comments, ...reviewThreads.comments] .map((comment) => ({ ...comment, - // GitHub keeps the dismissal reason on the timeline event, not on the review, - // so a dismissed review with nothing visible of its own reads its words from - // there. "Visible" and not "empty": bot reviews often carry only an HTML - // marker comment, which markdown renders as nothing. + // Dismissal reasons live on timeline events; HTML-only bot reviews need that fallback too. body: comment.kind === "review" && comment.reviewState?.toUpperCase() === "DISMISSED" && @@ -568,14 +553,10 @@ export const make = Effect.gen(function* () { input.host, reviewThreads.botLogins, ), - // A comment out of `gh pr view --json` carries none of its own: that read - // reports no reaction at all, so they arrive from the GraphQL page by node id. reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null, })) .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), - // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two - // are always whole and only the thread walk can stop short of the host. commentCount: pullRequest.comments.length + reviewThreads.commentCount, commentsTruncated: reviewThreads.truncated, reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, diff --git a/packages/source-control-github/src/server/GitHubQuota.test.ts b/packages/source-control-github/src/server/GitHubQuota.test.ts index 4ccbfe852c4ba..14365ee87d1f1 100644 --- a/packages/source-control-github/src/server/GitHubQuota.test.ts +++ b/packages/source-control-github/src/server/GitHubQuota.test.ts @@ -60,7 +60,6 @@ describe("GitHubQuota", () => { Effect.gen(function* () { const quota = yield* GitHubQuota.GitHubQuota; yield* quota.observe("github.com", headers(501, "core")); - // Conditional reads answer 304 with the balance unchanged, however many are sent. for (let index = 0; index < 5; index++) { yield* quota.admit("github.com", "core"); yield* quota.observe("github.com", headers(501, "core")); diff --git a/packages/source-control-github/src/server/GitHubQuota.ts b/packages/source-control-github/src/server/GitHubQuota.ts index e0da9f0b838a2..1cdf214f07bd7 100644 --- a/packages/source-control-github/src/server/GitHubQuota.ts +++ b/packages/source-control-github/src/server/GitHubQuota.ts @@ -37,10 +37,7 @@ function quotaFromHeaders( return { resource, limit, remaining, resetAtMs: reset * 1_000 }; } -/** - * Nothing is debited locally, because a 304 or a request that never reached GitHub spends - * nothing, and a local count would drift below GitHub's for the rest of the window. - */ +/** Local debits would drift: 304s and requests that never reach GitHub spend nothing. */ export class GitHubQuota extends Context.Service< GitHubQuota, { @@ -84,9 +81,7 @@ const make = Effect.gen(function* () { const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); yield* Ref.update(snapshots, (current) => { const previous = current.get(key); - // GitHub serves requests from several regions, so a later answer can report - // more left in the same window, and keeping the lower one would hold background - // work at the reserve until the reset. + // Regional responses can increase the balance within a window. if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; const next = new Map(current); next.set(key, { diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index a34467aaa4bba..b30d2645af54c 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -14,16 +14,11 @@ import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubQuota from "./GitHubQuota.ts"; const DEFAULT_TIMEOUT = Duration.seconds(30); -/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ +/** GitHub's secondary limits penalize concurrent bursts. */ const CONCURRENCY = 8; -/** The headers every GitHub request carries besides its token. */ const REQUEST_HEADERS = { "x-github-api-version": "2022-11-28", "user-agent": "t3code" }; -/** - * A credential already verified for one host. Every request made under it must target that - * host, so a page cannot read one account's data with another's token mid-flight. Server-local: - * never put its value in RPC payloads or cache keys. - */ +/** Pin requests to this host's credential across account switches. Keep it out of RPCs/cache keys. */ export const PinnedGitHubCredential = Context.Reference<{ readonly host: string; readonly token: Redacted.Redacted; @@ -72,11 +67,6 @@ export class GitHubApiNotFoundError extends Schema.TaggedError()( "GitHubApiResponseError", { @@ -106,10 +96,6 @@ export function normalizeHost(host: string): string { return host.trim().toLowerCase(); } -/** - * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` - * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. - */ export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { const normalized = normalizeHost(host); if (normalized === "github.com") { @@ -124,7 +110,6 @@ export function gitHubApiUrls(host: string): { readonly rest: string; readonly g return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; } -/** The numeric rate-limit headers GitHub sends, as span attributes. */ function rateLimitAttributes( headers: Readonly>, ): Record { @@ -143,7 +128,6 @@ function rateLimitAttributes( return attributes; } -/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, now: number, @@ -154,15 +138,10 @@ function retryAtFrom( return Number.isFinite(reset) && reset > now ? reset : undefined; } -/** - * Whether GitHub refused a request for its rate limit. A GraphQL answer can say so with HTTP 200: - * a `RATE_LIMITED` error, or an exhausted quota that answers an untyped "API rate limit already - * exceeded" error, which the headers confirm. - */ +/** HTTP 200 GraphQL rate limits use RATE_LIMITED or untyped errors with quota headers. */ export function isRateLimited(input: { readonly status: number; readonly headers: Readonly>; - /** The GraphQL `errors` list, when the answer is a GraphQL one and has any. */ readonly graphqlErrors?: ReadonlyArray<{ readonly type?: unknown; readonly message?: unknown }>; readonly body?: string; }): boolean { @@ -184,12 +163,7 @@ export function isRateLimited(input: { ); } -/** - * What one GitHub answer means, as the transport that read it decided: `Ok` carries what the - * request returns; every other verdict fails with the `GitHubApi*Error` that says why. - */ export type GitHubAnswer = { - /** Unset when the transport reported none. */ readonly status: number | undefined; readonly headers: Readonly>; } & ( @@ -208,23 +182,15 @@ export interface GitHubRequest { readonly timeout?: Duration.Input | undefined; } -/** - * What every GitHub request goes through, REST and GraphQL alike: the host's credential, one - * shared pool of request slots, the quota reserve and the host-wide pause before it is sent, and - * what GitHub's answer says about them after. Each transport only sends and reads its answer. - */ +/** REST/GraphQL share credentials, slots, quota reserves and host-wide pauses. */ export class GitHubRequestPolicy extends Context.Service< GitHubRequestPolicy, { - /** - * Sends one request. `send` receives the headers it must carry (the token and GitHub's API - * version) and reports the answer; a failure of its own passes through untouched. - */ + /** send receives auth/version headers; its failures pass through unchanged. */ readonly request: ( input: GitHubRequest, send: (headers: Readonly>) => Effect.Effect, E, R>, ) => Effect.Effect; - /** The credential a request to `host` would carry right now. */ readonly credential: ( host: string, ) => Effect.Effect< @@ -234,7 +200,7 @@ export class GitHubRequestPolicy extends Context.Service< } >()("@t3tools/source-control-github/server/GitHubRequestPolicy") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const credentials = yield* GitHubCredentials.GitHubCredentials; const quota = yield* GitHubQuota.GitHubQuota; @@ -247,7 +213,6 @@ export const make = Effect.gen(function* () { const normalized = normalizeHost(host); const pinned = yield* PinnedGitHubCredential; if (pinned !== null) { - // A pinned page only ever talks to the host it verified. if (pinned.host !== normalized) { return yield* new GitHubApiAuthenticationError({ host: normalized, diff --git a/packages/source-control-github/src/server/GitHubRest.test.ts b/packages/source-control-github/src/server/GitHubRest.test.ts index 6c3f653120ee9..a4ca7a14c77ab 100644 --- a/packages/source-control-github/src/server/GitHubRest.test.ts +++ b/packages/source-control-github/src/server/GitHubRest.test.ts @@ -117,7 +117,6 @@ describe("GitHubRest", () => { "http.response.status_code": 200, "github.ratelimit.remaining": 4800, }); - // Nothing recorded carries the query string or the token. const everything = spans .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) .join("\n"); diff --git a/packages/source-control-github/src/server/GitHubRest.ts b/packages/source-control-github/src/server/GitHubRest.ts index a29505ecc7af9..28a834172192b 100644 --- a/packages/source-control-github/src/server/GitHubRest.ts +++ b/packages/source-control-github/src/server/GitHubRest.ts @@ -22,7 +22,7 @@ export interface GitHubRestResponse { readonly headers: Readonly>; readonly body: string; readonly truncated: boolean; - /** The body was not valid UTF-8, which a raw file read takes to mean binary. */ + /** Invalid UTF-8 indicates a binary raw file. */ readonly invalidUtf8: boolean; } @@ -30,12 +30,12 @@ export interface GitHubRestInput { readonly host: string; readonly operation: string; readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ + /** Relative to the API root, including query string. */ readonly path: string; readonly body?: unknown; /** Defaults to `application/vnd.github+json`. */ readonly accept?: string; - /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ + /** Revalidate cached answers; 304 returns successfully without spending quota. */ readonly ifNoneMatch?: string; readonly maxResponseBytes?: number; /** Defaults to 30 seconds; a whole pull request's patch may need longer. */ @@ -48,7 +48,6 @@ export class GitHubRest extends Context.Service< GitHubRest, { readonly rest: (input: GitHubRestInput) => Effect.Effect; - /** The credential a request to `host` would carry right now. */ readonly credential: ( host: string, ) => Effect.Effect< @@ -58,7 +57,6 @@ export class GitHubRest extends Context.Service< } >()("@t3tools/source-control-github/server/GitHubRest") {} -/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ const decodeRestErrors = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -98,7 +96,7 @@ function restErrorMessages(body: string): ReadonlyArray | undefined { }); } -/** What a REST answer means: a 304 is an answer only when the request revalidated a cached one. */ +/** Accept 304 only for cache revalidation. */ function classify(input: { readonly answer: GitHubRestResponse; readonly acceptNotModified: boolean; @@ -116,15 +114,11 @@ function classify(input: { return { _tag: "Failed", messages: restErrorMessages(body), ...meta }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const policy = yield* GitHubRequestPolicy.GitHubRequestPolicy; - /** - * Sends one request through the request policy and fails a refusal with the error that says - * why. The answer is returned only once it is known to be one. - */ const send = Effect.fn("GitHubRest.send")(function* (input: { readonly host: string; readonly operation: string; @@ -135,7 +129,7 @@ export const make = Effect.gen(function* () { readonly acceptNotModified: boolean; }) { const host = normalizeHost(input.host); - // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. + // Query strings can expose SHAs or branches in traces. yield* Effect.annotateCurrentSpan({ "github.kind": "rest", "http.request.method": input.request.method, @@ -145,8 +139,7 @@ export const make = Effect.gen(function* () { { host, operation: input.operation, - // Every REST path this reads today spends `core`; a `search/` read would need its own - // resource here. + // All current paths spend `core`; search would need a different resource. resource: "core", allowReserve: input.allowReserve, timeout: input.timeout, @@ -156,15 +149,13 @@ export const make = Effect.gen(function* () { const response: HttpClientResponse.HttpClientResponse = yield* httpClient .execute(input.request.pipe(HttpClientRequest.setHeaders(headers))) .pipe( - // `GitHubRest.send` is the request's span. The client's own span would add - // `url.full` and `url.query`, which can carry SHAs and branch names, so it is off. + // Client spans expose SHAs and branches through url.full/url.query. Effect.provideService(HttpClient.TracerDisabledWhen, () => true), ); const collected = yield* collectUint8StreamText({ stream: response.stream, maxBytes: input.maxResponseBytes, }).pipe( - // 204, 304 and many refusals carry no body at all, which is an empty answer. Effect.catchIf( (error) => error.reason._tag === "EmptyBodyError", () => Effect.succeed({ text: "", truncated: false, invalidUtf8: false }), @@ -223,10 +214,6 @@ export const make = Effect.gen(function* () { return GitHubRest.of({ rest, credential: policy.credential }); }); -/** - * The REST transport together with the request policy it sends through. The generated GraphQL - * client sends through the same policy, so REST and GraphQL share one set of request slots. - */ export const layer = Layer.effect(GitHubRest, make).pipe( Layer.provideMerge(GitHubRequestPolicy.layer), ); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 60e6a7eada87c..68a9da96d3a3f 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -80,10 +80,6 @@ const node = (number: number, headRefName: string, owner = "acme") => ({ headRepositoryOwner: { __typename: "User", login: owner }, }); -/** - * One GraphQL request as the fake GitHub saw it. An aliased batch is split back into its - * lookups, each with its own variables, in order. - */ interface GraphQlCall { readonly host: string; readonly operationName: string; @@ -91,17 +87,12 @@ interface GraphQlCall { readonly lookups: ReadonlyArray>>; } -/** - * What GitHub answers for one lookup: the `repository` it reads, or a whole HTTP response for the - * request (a refusal, a rate limit). - */ type GraphQlAnswer = unknown; type GraphQlRespond = ( lookup: Readonly>, call: GraphQlCall, ) => GraphQlAnswer | Response; -/** GitHub's GraphQL endpoint for the generated reads: answers each lookup with `respond`. */ function graphQlHttp(respond: GraphQlRespond, calls: Array) { return HttpClient.make((request) => Effect.sync(() => { @@ -153,16 +144,10 @@ function graphQlHttp(respond: GraphQlRespond, calls: Array) { ); } -/** - * The provider over a mocked GitHubRest, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. GraphQL reads go over HTTP to - * `graphql`, through the real request policy; `graphqlCalls` records each request. - */ function harness(input: { readonly remotes: string; readonly api?: Partial; readonly graphql?: GraphQlRespond; - /** The credential a GraphQL read is sent with; github.com's token by default. */ readonly credential?: ( host: string, ) => Effect.Effect< @@ -250,8 +235,6 @@ function harness(input: { return { layer, git, graphqlCalls }; } -/** The provider context for a checkout whose remote is on `host`. */ -/** The provider over a mocked GitHubRest, for reads that never touch the checkout. */ const makeProvider = (api: Partial) => GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 4d25e97f8db38..dac6fdb842499 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -70,13 +70,9 @@ import { PullRequestsByHead, } from "./sourceControlProvider.graphql.ts"; -/** - * The generated head lookup with its rows left undecoded, so `listByHead` decodes them one at a - * time: a row whose fields no longer decode is skipped rather than failing every lookup that - * shared its document. - */ +// Decode rows separately to isolate malformed PRs within shared documents. const LenientPullRequestsByHead = GraphQL.query(PullRequestsByHead.name, { - // The template, not the joined text: `GraphQLResolver.aliased` repeats it per lookup. + // `GraphQLResolver.aliased` needs a template per lookup. document: PullRequestsByHead.template!, variables: PullRequestsByHead.variables, result: Schema.Struct({ @@ -168,8 +164,7 @@ export function parseGitHubAuth( }; } - // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit - // non-zero, which reads exactly like a signed-out CLI. + // gh before 2.81.0 rejects --json with a nonzero exit that resembles a signed-out CLI. if (input.exitCode !== 0 && output.includes("unknown flag: --json")) { return providerAuth({ status: "unknown", @@ -211,10 +206,7 @@ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null; } -/** - * An environment token is checked against the API, since `gh auth status` may - * not know it. - */ +// `gh auth status` may not know environment tokens; check the API. export const makeDiscovery = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; const sourceControlHost = yield* SourceControlHost.SourceControlHost; @@ -282,10 +274,7 @@ export const makeDiscovery = Effect.gen(function* () { }, } satisfies SourceControlProviderDiscoveryItem; }), - /** - * A host turned off in Settings is claimed too, so its error says so - * instead of "unsupported host". - */ + // Claim disabled hosts so errors identify the setting. refineUnknownRemote: ({ context }) => { const url = URL.parse(context.provider.baseUrl); if (!url?.host) return Effect.succeed(null); @@ -383,24 +372,12 @@ const GRAPHQL_STATES: Record< merged: ["MERGED"], all: ["OPEN", "CLOSED", "MERGED"], }; -/** - * A document of a hundred costs one point, the same as one single lookup, but - * half that keeps each answer near half a second. - */ +/** 50 lookups keeps responses near 0.5s at the same one-point cost as 100. */ const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * Branch discovery reaches GitHub only after each branch's own git reads, so lookups started - * together arrive tens of milliseconds apart. A background sweep's lookups spread over up to - * ~300ms, and every document costs a point no matter how few heads it holds, so reads - * without reserve wait longer. - */ +/** Git reads stagger background lookups by ~300ms; gather batches before spending quota. */ const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ +/** For heads matching 100 PRs each, 50 lookups approached GitHub's ~10s limit; 25 took ~7s. */ const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; const OWNER_HEAD_SCAN_LIMIT = 100; @@ -410,12 +387,7 @@ function trimmed(value: string): string | null { return text.length > 0 ? text : null; } -/** - * A pull request as every read reports it, or null for a row this cannot use: a number below - * one, a blank title, URL or branch name, or an `updatedAt` that is not a date. A list skips a - * null, so one such row cannot hide the rest. The generated schema already holds every row to - * GitHub's own types, with `state` as a lenient enum. - */ +/** Null isolates unusable PRs from the rest of a listing. */ function toPullRequestRecord(node: PullRequestNode): NormalizedGitHubPullRequestRecord | null { const title = trimmed(node.title); const url = trimmed(node.url); @@ -513,10 +485,6 @@ export const make = Effect.gen(function* () { ), ).pipe(Effect.provide(GitHubGraphQlClient.layer)); - /** - * Runs a generated read against `target`. GitHub's refusals and `errors` fail the way REST - * ones do; an answer that does not decode is a `decodeDetail` failure. - */ const graphqlRead = ( target: GitHubGraphQlClient.GitHubGraphQlTarget["Service"], decodeDetail: string, @@ -534,13 +502,7 @@ export const make = Effect.gen(function* () { const rest = (input: GitHubRest.GitHubRestInput, notFoundDetail?: string) => api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); - /** - * Head lookups to one host under one credential share aliased documents: the resolver batches by - * the host's endpoint and `batchKey`. Each lookup has its own `repository` root, so lookups on - * different repositories may share a document, and a repository GitHub cannot find fails only - * its own. Interactive and background lookups never share one, since they use separate - * resolvers, so a document is sent under one reserve setting. - */ + // Batch by host/credential; isolate missing repositories and interactive/background reserves. const headResolver = GraphQLResolver.aliased(client.PullRequestsByHead, { batchKey: GitHubGraphQlClient.batchKey, }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); @@ -570,8 +532,7 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "listPullRequestsByHead", allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. + // 50 default-branch lookups can each match 100 fork PRs and exceed the usual cap. maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, }, "GitHub returned an invalid change request list.", @@ -679,8 +640,7 @@ export const make = Effect.gen(function* () { return decoded.success.login; }); - // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client - // sees is fixed and the raw error stays in `cause`. + // Keep paths, argv and stderr in cause; expose only a fixed checkout error to clients. const gitFailure = (cause: unknown) => failure("The pull request could not be checked out with git.", cause); @@ -691,9 +651,6 @@ export const make = Effect.gen(function* () { args, }); - /** - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( function* (input: { readonly cwd: string; @@ -728,9 +685,7 @@ export const make = Effect.gen(function* () { const known = remoteFor(baseNameWithOwner); return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. + // Avoid resetting the default branch; prefix colliding forks and fail if it is unknown. const defaultBranch = isCrossRepository ? yield* readRepository(base).pipe( Effect.map((repository) => repository.default_branch ?? null), @@ -806,8 +761,7 @@ export const make = Effect.gen(function* () { : ["merge", "--ff-only", "--quiet", target.ref], ); } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. + // Set tracking after sync succeeds so a failed checkout preserves the existing upstream. if (target.upstream !== null) { yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); } @@ -908,11 +862,9 @@ export const make = Effect.gen(function* () { return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("github"), refineRepositoryIdentity, - // `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; the bare branch is always among the selectors. Without the owner, - // a bare branch also lists same-named branches on other forks (`main`, `patch-1`), so read a - // full page and let the owner check pick the right head. + // `gh pr list --head` accepts only bare ref names; qualified selectors waste + // a GraphQL call and return nothing. Bare names also match other forks, so + // read a full page and check the owner to select the right head. headBranchProbe: ({ headSelectors }) => ({ headSelectors: headSelectors.filter((selector) => !selector.includes(":")), limit: 100, @@ -1018,8 +970,7 @@ export const make = Effect.gen(function* () { method: "POST", allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. + // Explicitly allow maintainer edits to match gh; the API default is undocumented. body: { base: input.baseRefName, head: input.headSelector, @@ -1079,7 +1030,6 @@ export const make = Effect.gen(function* () { isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, body: { name, private: input.visibility === "private" }, }, - // GitHub answers 404 for an organization the account cannot create repositories in. `No organization named ${owner ?? "that"} that this account can create repositories in.`, ); const decoded = decodeRawRepository(response.body); diff --git a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts index 5b81c198da5a5..c6af80389d1f7 100644 --- a/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts +++ b/packages/source-control-github/src/server/gitHubChangeRequestTemplate.ts @@ -131,8 +131,7 @@ export const detect = Effect.fn("gitHubChangeRequestTemplate.detect")(function* executeGit: ExecuteGit, ) { return yield* Effect.gen(function* () { - // Worktree paths can be replaced between validation and open. Read regular blobs from the - // committed base tree so repository-controlled symlinks and path races never reach the host filesystem. + // Read committed regular blobs; worktree paths can become symlinks between validation and open. const result = yield* executeGit({ operation: "gitHubChangeRequestTemplate.listTemplates", cwd, diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts index c0698f2e7f618..5027e618f9e93 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.test.ts @@ -53,7 +53,6 @@ const json = (body: unknown, init?: ResponseInit) => headers: { "content-type": "application/json", ...init?.headers }, }); -/** One stats lookup on `host`, through the client and middleware the pull request reads use. */ const readStats = (host: string, number = 7) => Effect.gen(function* () { const client = yield* GraphQLClient.make( @@ -120,7 +119,6 @@ describe("GitHubGraphQlAuth", () => { yield* TestClock.setTime(NOW); const first = yield* Effect.flip(readStats("github.com")); expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); - // The pause is the one REST reads respect too. const api = yield* GitHubRest.GitHubRest; const rest = yield* Effect.flip( api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), @@ -218,11 +216,9 @@ describe("GitHubGraphQlAuth", () => { yield* readStats("github.com", 1).pipe( Effect.provideService(SourceControlRateLimit.Interactive, true), ); - // 100 of 5,000 left is inside the reserve, which a background read may not spend. const background = yield* Effect.flip(readStats("github.com", 2)); expect(background._tag).toBe("SourceControlRateLimitPausedError"); expect(requests).toHaveLength(1); - // REST spends a quota of its own, which the GraphQL reserve does not hold back. const api = yield* GitHubRest.GitHubRest; yield* api.rest({ host: "github.com", operation: "user", path: "user" }); expect(requests).toHaveLength(2); @@ -230,7 +226,6 @@ describe("GitHubGraphQlAuth", () => { }); }); -/** One unbatched stats read on github.com, through `call`. */ const callStats = (target: Partial = {}) => Effect.gen(function* () { const client = yield* GraphQLClient.make( diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 427369ea510d9..8ea03b9eb52f1 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -14,10 +14,7 @@ import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; -/** - * The GitHub host and operation a generated GraphQL call is sent as. One client serves every host, - * so each call names its own. - */ +/** One client serves all hosts; each call must supply its target. */ export class GitHubGraphQlTarget extends Context.Service< GitHubGraphQlTarget, { @@ -30,7 +27,7 @@ export class GitHubGraphQlTarget extends Context.Service< } >()("@t3tools/source-control-github/server/gitHubGraphQlClient/GitHubGraphQlTarget") {} -/** A stable short id for a GraphQL document, so traces group by query without its text. */ +// Group traces by query without exposing query text. function queryHash(query: string): string { let hash = 0x811c9dc5; for (let index = 0; index < query.length; index++) { @@ -40,21 +37,12 @@ function queryHash(query: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } -/** - * Whether GitHub's `errors` all say the thing asked for does not exist, or that the credential - * cannot see it. A batch reports that for one pull request alone. - */ export const isNotFoundOnly = (error: unknown): boolean => GraphQLClientError.isGraphQLClientError(error) && error.reason._tag === "ResponseError" && error.reason.errors.every((graphQlError) => graphQlError["type"] === "NOT_FOUND"); -/** - * GitHub's auth and rate limits around a generated GraphQL call, through `GitHubRequestPolicy`: - * the same credential, request slots, quota and pause as REST. It fails with the policy's - * errors, which every GitHub caller already maps. GraphQL `errors` that are not a rate limit are - * the client's: a batch settles them per pull request, and `call` maps the rest. - */ +/** Share REST's credentials/slots/quota/pause; leave non-rate-limit errors to each lookup. */ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< GitHubGraphQlAuth, { requires: GitHubGraphQlTarget; error: GitHubRequestPolicy.GitHubApiError } @@ -182,12 +170,7 @@ const capResponse = Effect.fn("gitHubGraphQlClient.capResponse")(function* ( ); }); -/** - * The HTTP transport, sending each request to its target host's endpoint. Resolvers batch by this - * URL, so lookups for different hosts never share a request. `GitHubGraphQlAuth` requires the - * target, so every call has one. The middleware's span is the request's: the client's own would add - * `url.full`, which can carry SHAs and branch names, so it is off, as for REST. - */ +// Endpoint URLs isolate hosts. Disable client tracing to protect SHAs and branches in url.full. const layerProtocol = GraphQLProtocol.layerHttp({ url: ({ context }) => GitHubRequestPolicy.gitHubApiUrls(Context.getUnsafe(context, GitHubGraphQlTarget).host).graphql, @@ -207,17 +190,9 @@ const layerProtocol = GraphQLProtocol.layerHttp({ ), ); -/** - * What a client built over `.middleware(GitHubGraphQlAuth)` needs beyond `GitHubRequestPolicy` - * and the HTTP client. - */ export const layer = Layer.merge(GitHubGraphQlAuth.layer, layerProtocol); -/** - * What decides which lookups to one host may share a batched request: the credential and - * rate-limit scope it will be sent under. Read from the context the lookup was made in. Resolvers - * already keep hosts apart, since they batch by the transport's URL too. - */ +/** Isolate credentials/rate-limit scopes; transport URLs already separate hosts. */ export const batchKey = (context: Context.Context) => JSON.stringify([ Context.getOrElse(context, GitHubRequestPolicy.PinnedGitHubCredential, () => null) @@ -225,11 +200,6 @@ export const batchKey = (context: Context.Context) => Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), ]); -/** - * GitHub's `errors` on an answer, as the policy's errors: not found when every error says so, - * otherwise a response error carrying GitHub's messages. Any other client error (an answer that - * does not decode) is returned as it is. - */ const fromClientError = ( target: { readonly host: string; readonly operation: string }, error: GraphQLClientError.GraphQLClientError, @@ -252,12 +222,7 @@ const fromClientError = ( }); }; -/** - * Runs one generated GraphQL call (or several) against `target`, failing GitHub's `errors` as - * `GitHubApiNotFoundError` or `GitHubApiResponseError`. What remains a `GraphQLClientError` is an - * answer that did not decode, which the caller reports as its own read error. The middleware's - * errors pass through. - */ +/** Map response errors to policy errors; callers map decode errors to read errors. */ export const call: { ( target: GitHubGraphQlTarget["Service"], @@ -278,7 +243,6 @@ export const call: { >; } = dual(2, (effect: Effect.Effect, target: GitHubGraphQlTarget["Service"]) => effect.pipe( - // A `GraphQLClientError` left unmapped is one `E` already holds. Effect.mapError((error) => GraphQLClientError.isGraphQLClientError(error) ? (fromClientError(target, error) as diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 09a918352446b..791118cd0eef9 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -34,10 +34,6 @@ import { } from "./pullRequestReads.graphql.ts"; import { ReviewThreadComments, ReviewThreads } from "./pullRequestReviews.graphql.ts"; -/** - * A GitHub answer (`{ data }` as JSON) through a generated operation's result schema and then its - * mapping, the way the client decodes and the API maps. A mapping's null (nothing found) fails. - */ const through = , A>(schema: S, map: (result: S["Type"]) => A | null) => (raw: string): Result.Result => { @@ -49,14 +45,12 @@ const through = return mapped === null ? Result.fail("not found") : Result.succeed(mapped); }; -/** An account as a selection with `__typename` answers it: GitHub always sends the type. */ const user = (login: string, fields: Record = {}) => ({ __typename: "User", login, ...fields, }); -/** A listing row with every field the row selection asks for, as GitHub always answers it. */ const row = (fields: Record) => ({ number: 1, title: "Add the pull requests page", @@ -94,11 +88,6 @@ const decodePullRequestActivityJson = through( toPullRequestActivityPage, ); const decodePullRequestCoreJson = through(PullRequestCore.result, toPullRequestCore); -/** - * Fills in what every review read answers with, so each fixture below says only what it is about: - * a page with no more after it, an actor typed as a person, no reactions, no edits. - */ -// Fixtures are loose JSON on purpose: each one writes only the fields its test is about. type Json = Record; const LAST_PAGE = { hasNextPage: false, endCursor: null }; const nodesIn = (connection: Json | null | undefined): ReadonlyArray => @@ -318,7 +307,6 @@ describe("pull request list decoding", () => { }); describe("pull request detail decoding", () => { - /** A check run or commit status as the core read's `CheckContext` fragment answers it. */ const check = (fields: Record) => fields["__typename"] === "StatusContext" ? { @@ -340,14 +328,12 @@ describe("pull request detail decoding", () => { checkSuite: { workflowRun: null }, ...fields, }; - /** A check run owned by a workflow, which is how the core read names the workflow. */ const inWorkflow = ({ workflowName, ...fields }: Record) => check({ ...fields, checkSuite: { workflowRun: { workflow: { name: workflowName } } }, }); - /** The core read's answer for pull request 7, with `rollup` as its head commit's checks. */ const coreJson = ( rollup: ReadonlyArray>, pullRequest: Record = {}, @@ -413,7 +399,6 @@ describe("pull request detail decoding", () => { pullRequest?: Record, ) => expectSuccess(decodePullRequestCoreJson(coreJson(rollup, pullRequest))); - /** The conversation the activity read carries, in the flat shape the tests below write. */ const detailJson = JSON.stringify({ comments: [{ id: "c1", body: "second", createdAt: "2026-07-04T00:00:00Z" }], reviews: [ @@ -509,7 +494,6 @@ describe("pull request detail decoding", () => { autoMergeMethod: "squash", }); expect(detailOf([], { autoMergeRequest: null }).autoMergeEnabled).toBe(false); - // A detail read that never asked about auto-merge does not claim it is unarmed. expect( expectSuccess( decodePullRequestDetailJson( @@ -528,8 +512,6 @@ describe("pull request detail decoding", () => { }); it("shows a re-running check once, as the run that is happening now", () => { - // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, - // the finished run and the one that replaced it, with no id to tell them apart. const detail = detailOf([ inWorkflow({ name: "Prepare PR size config", @@ -556,8 +538,6 @@ describe("pull request detail decoding", () => { }); it("keeps a check pending while another run of it on the same commit is still going", () => { - // A workflow on both `push` and `pull_request` runs the same check twice on one commit. The - // `pull_request` run finished after the `push` run started, and the `push` run is still going. const finished = { name: "check", workflowName: "Checks", @@ -783,8 +763,7 @@ describe("review thread decoding", () => { }); it("keeps the conversation when a reviewer has neither a login nor a team slug", () => { - // GraphQL answers with an empty object for a union member the query has no fragment for, - // such as a mannequin. Failing on it would take the whole response down, comments included. + // Unselected GraphQL union members return no selected fields. const result = expectSuccess( decodeReviewThreadsJson( reviewJson({ @@ -978,8 +957,6 @@ describe("repository access decoding", () => { }); it("withholds write where gh names no permission, which is not a standing it gave", () => { - // The one place an unknown answer is not granted: a Merge button a reader cannot use wastes - // the press, where a missing one still leaves the pull request open on its host. expect(toCanWrite(undefined)).toBe(false); }); @@ -1026,9 +1003,6 @@ describe("viewer permission decoding", () => { }); it("reads silence as permission, but not as authorship", () => { - // A node the viewer cannot see comes back null. Updating is a permission, so an unknown - // answer grants it and lets the host refuse; authorship is a fact about who wrote the change, - // and claiming it for someone who did not is how an author's own rules get handed out. expect( expectSuccess( decodeViewerPermissionsJson(viewerJson({ viewerPermission: "READ", pullRequest: null })), diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 90a1eaae31141..71c86e630f740 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -73,7 +73,6 @@ import type { ThreadComment, } from "./pullRequestReviews.graphql.ts"; -/** The parts of an actor the mappings below read, whichever selection it came from. */ interface ActorFields { readonly __typename?: string | undefined; readonly login?: string | null | undefined; @@ -81,10 +80,6 @@ interface ActorFields { readonly avatarUrl?: string | null | undefined; } -/** - * A listing row before mapping. Optional where a selection leaves a field out (the search's line - * counts), and a check rollup reduced to the shape the detail's checks have. - */ interface ListItemFields { readonly number: number; readonly title: string; @@ -118,15 +113,11 @@ interface ReviewRequestFields { readonly name?: string | null | undefined; } -/** One reviewer's most recent review: the state is all the verdict needs. */ interface LatestReviewFields { readonly state?: string | null | undefined; } -/** - * A check as the rollup reports it, a check run and a commit status alike. A check run reports - * `status` plus a `conclusion` once it has completed; a commit status a single `state`. - */ +/** Check runs use status/conclusion; commit statuses use a single state. */ interface CheckFields { readonly name?: string | null | undefined; readonly context?: string | null | undefined; @@ -136,10 +127,7 @@ interface CheckFields { readonly description?: string | null | undefined; readonly detailsUrl?: string | null | undefined; readonly targetUrl?: string | null | undefined; - /** - * What tells two same-named checks apart, and which run of one is the newest. Empty for an - * app-provided check run, which belongs to no workflow, and absent on a commit status. - */ + /** Empty for app-owned runs; absent for commit statuses. */ readonly workflowName?: string | null | undefined; readonly startedAt?: string | null | undefined; readonly completedAt?: string | null | undefined; @@ -147,13 +135,11 @@ interface CheckFields { readonly isRequired?: boolean | null | undefined; } -/** Where a connection carries on from, which is what every paged read follows. */ interface PageInfoFields { readonly hasNextPage?: boolean | undefined; readonly endCursor?: string | null | undefined; } -/** The connection's nodes without the nulls GraphQL allows in a list. */ function nodesOf( connection: { readonly nodes?: ReadonlyArray | null | undefined } | null | undefined, ): ReadonlyArray { @@ -190,12 +176,7 @@ export function gitHubReactionContent( return GITHUB_REACTION_BY_CONTENT[content]; } -/** - * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is - * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The - * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it - * in would name them twice — but `count` still counts them along with everyone else. - */ +/** Keep the viewer in counts, not actors, to avoid duplicating 'You'. */ function toReactions( groups: ReadonlyArray | null | undefined, viewer: string | null, @@ -219,7 +200,6 @@ function toReactions( return reactions; } -/** `GET /repos/{owner}/{repo}/actions/runs`, one page of it. */ const RawWorkflowRunsSchema = Schema.Struct({ workflow_runs: Schema.Array( Schema.Struct({ @@ -239,7 +219,6 @@ const RawPullRequestFileSchema = Schema.Struct({ deletions: Schema.optional(Schema.NullOr(Schema.Int)), }); -/** A listing's authors' avatars by login, from their node ids. */ export function toActorAvatars(result: ActorAvatars.Result): ReadonlyMap { const avatarsByLogin = new Map(); for (const node of result.nodes) { @@ -251,7 +230,6 @@ export function toActorAvatars(result: ActorAvatars.Result): ReadonlyMap | null { @@ -264,28 +242,19 @@ export function toPullRequestPreview( }; } -/** - * The ceiling on `search`, which refuses anything larger with EXCESSIVE_PAGINATION (measured: - * `first: 101` is an error, `first: 100` is not). - */ +/** Search rejects first > 100 with EXCESSIVE_PAGINATION. */ export const PULL_REQUEST_SEARCH_MAX_ROWS = 100; -/** A row count a listing read may ask for. */ export function pageRows(rows: number): number { return Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS); } -/** The pull request's own node id, or null where GitHub found none. */ export function toPullRequestNodeId(result: { readonly repository: { readonly pullRequest: { readonly id: string } | null } | null; }): string | null { return result.repository?.pullRequest?.id ?? null; } -/** - * True when the subject named is the pull request itself, or hangs off it — false for anything - * else, including a subject or a pull request this host could not find. - */ export function isSubjectOfPullRequest(result: PullRequestSubject.Result): boolean { const expected = result.repository?.pullRequest?.id ?? null; const node = result.node; @@ -302,7 +271,6 @@ export function isSubjectOfPullRequest(result: PullRequestSubject.Result): boole return expected !== null && actual !== null && expected === actual; } -/** The body of `POST /repos/{owner}/{repo}/pulls/{number}/reviews`, which sends a review whole. */ const ReviewSubmissionSchema = Schema.Struct({ event: Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]), body: Schema.String, @@ -425,30 +393,12 @@ function trimmed(value: string | null | undefined): string | null { return text.length > 0 ? text : null; } -/** - * GitHub sends an `endCursor` on a page that is also the last one, so the flag is what - * decides, not the cursor's presence. - */ +/** GitHub returns endCursor even on the last page; hasNextPage is authoritative. */ function nextCursorOf(pageInfo: PageInfoFields | null | undefined): string | null { return pageInfo?.hasNextPage === true ? trimmed(pageInfo.endCursor) : null; } -/** - * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. - * - * Updating is a permission, so an install that does not report it grants it and lets the host's - * own refusal explain anything that fails. Authorship is not a permission but a fact about who - * wrote the thing, and it is read to decide what an author may do to their own change — so an - * unknown answer is "not the author", which grants nothing it should not. - */ -/** - * The viewer's standing on one pull request. The two halves take opposite defaults on purpose. - * - * Updating is a permission, so an install that does not report it grants it and lets the host's - * own refusal explain anything that fails. Authorship is not a permission but a fact about who - * wrote the thing, and it is read to decide what an author may do to their own change — so an - * unknown answer is "not the author", which grants nothing it should not. - */ +/** Defer unknown update access to the host; unknown authorship grants no author actions. */ function toPullRequestViewerFields( raw: | { @@ -473,17 +423,13 @@ function toActor(raw: ActorFields | null | undefined): PullRequestActor | null { }; } -/** A requested team as the reads select it: `org/slug`, with its name and avatar aliased apart. */ interface RequestedTeamFields { readonly combinedSlug?: string | null | undefined; readonly teamName?: string | null | undefined; readonly teamAvatarUrl?: string | null | undefined; } -/** - * A requested team as a reviewer row. It goes by `org/slug`, the handle GitHub mentions it by. - * No GitHub login contains a slash, so a team never shares a row or a verdict with a user. - */ +/** org/slug avoids collisions with user logins and verdicts. */ function toRequestedTeam(raw: object | null | undefined): PullRequestActor | null { if (raw == null || !("combinedSlug" in raw)) return null; const team = raw as RequestedTeamFields; @@ -498,13 +444,11 @@ function toCommitActor(raw: { readonly login?: string | null | undefined; readonly name?: string | null | undefined; }): PullRequestActor | null { - // An email-linked GitHub account has a login; an unlinked signature only has a name or email. - // Keep that signature visible instead of silently turning a co-authored commit into one author. + // Unlinked commit signatures have no login; preserve name/email so co-authors remain visible. const login = trimmed(raw.login) ?? trimmed(raw.name) ?? trimmed(raw.email); return login === null ? null : { login, name: trimmed(raw.name), avatarUrl: null }; } -/** An author off the review read's commits, which names an account by `user.login`. */ function toGraphqlCommitActor(raw: { readonly name?: string | null | undefined; readonly avatarUrl?: string | null | undefined; @@ -551,11 +495,7 @@ function toMergeMethod(value: string | null | undefined): PullRequestMergeMethod } } -/** - * GitHub's own `reviewDecision` counts only reviews that satisfy the branch rules, so an - * approval from an app (a review bot) or from anyone without the required permission leaves it - * empty. - */ +/** GitHub's reviewDecision ignores reviews outside branch rules, including app approvals. */ function toReviewDecisionWithReviews( value: string | null | undefined, reviews: ReadonlyArray | undefined, @@ -606,8 +546,6 @@ function hasTeamReviewRequest(raw: ReadonlyArray | undefine } function toCheckStatus(raw: CheckFields): PullRequestCheckStatus { - // Commit statuses report a single `state`; check runs report `status` plus a `conclusion` - // that only exists once the run has completed. const status = raw.status?.trim().toUpperCase(); if (status !== undefined && status !== "COMPLETED" && status !== "") { return "pending"; @@ -634,7 +572,7 @@ function toCheckStatus(raw: CheckFields): PullRequestCheckStatus { } } -/** What GitHub writes where a run has not reached that moment yet, which is not a time. */ +/** GitHub's sentinel for timestamps not reached yet. */ const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; const STILL_RUNNING = "9999-12-31T23:59:59Z"; @@ -644,16 +582,11 @@ function realTimestamp(value: string | null | undefined): string | null { return at === null || at === UNSET_TIMESTAMP ? null : at; } -/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ function isNamelessCheck(raw: CheckFields): boolean { return trimmed(raw.name) === null && trimmed(raw.context) === null; } -/** - * A run that has not finished outranks any finished run of the same check: a workflow triggered - * by both `push` and `pull_request` runs the check twice on one commit, and the run that - * finished first must not hide the one still going. - */ +/** Unfinished runs outrank finished runs of the same check across push/PR workflows. */ function toCheckEntries(raw: ReadonlyArray | null | undefined): ReadonlyArray<{ readonly check: PullRequestCheck; readonly workflowName: string | null; @@ -737,10 +670,7 @@ function toComments(raw: { path: null, reviewState: null, })); - // A review with no body is kept only when its state is the event itself — an approval, a - // request for changes, a dismissal. GitHub also opens a bodiless `COMMENTED` review as the - // container for line comments, and those comments are read from the review threads, so - // keeping the container too would show a row with a name and nothing under it. + // Bodiless COMMENTED reviews only hold line comments read separately. const reviews = raw.reviews.flatMap((review): ReadonlyArray => { const submittedAt = trimmed(review.submittedAt); const reviewState = trimmed(review.state); @@ -816,7 +746,6 @@ interface DetailFields extends ListItemFields { readonly body?: string | undefined; readonly changedFiles?: number | undefined; readonly closedAt?: string | null | undefined; - /** The standing instruction and strategy GitHub will use once its requirements are met. */ readonly autoMergeRequest?: | { readonly mergeMethod?: string | null | undefined } | null @@ -861,24 +790,18 @@ const decodeWorkflowRuns = decodeJsonResult(RawWorkflowRunsSchema); const decodeFileEntry = Schema.decodeUnknownExit(RawPullRequestFileSchema); const decodeRow = Schema.decodeUnknownExit(PullRequestRow); -/** - * A listing page whose rows are left undecoded, so `toPullRequestRows` can decode them one at a - * time: a node that is not a pull request, or one field GitHub changes, is skipped rather than - * failing the page and blanking every repository at once. - */ +/** Decode rows individually so one invalid node cannot blank the entire listing. */ const LenientRows = Schema.Struct({ pageInfo: ListingPageInfo, nodes: Schema.NullOr(Schema.Array(Schema.Unknown)), }); -/** The generated search, with its rows decoded by `toPullRequestRows` instead. */ export const LenientPullRequestSearch = GraphQL.query(PullRequestSearch.name, { document: PullRequestSearch.document, variables: PullRequestSearch.variables, result: Schema.Struct({ search: LenientRows }), }); -/** The generated repository list, with its rows decoded by `toPullRequestRows` instead. */ export const LenientPullRequestList = GraphQL.query(PullRequestList.name, { document: PullRequestList.document, variables: PullRequestList.variables, @@ -889,11 +812,7 @@ export const LenientPullRequestList = GraphQL.query(PullRequestList.name, { type DecodeFailure = Cause.Cause; -/** - * A listing page's rows, decoded one at a time against the generated row: a node that is not a pull - * request, or one whose fields no longer decode, is skipped rather than blanking the whole page. - * `rawCount` counts them all, so a skipped row cannot hide a next page. - */ +/** Count skipped invalid rows in rawCount to avoid ending pagination early. */ export function toPullRequestRows( connection: | { readonly pageInfo: PageInfoFields; readonly nodes: ReadonlyArray | null } @@ -941,8 +860,6 @@ function toRow( latestReviews: nodesOf(node.latestReviews), reviewRequests, labels: nodesOf(node.labels), - // The rollup arrives as one enum. Dressed as a single check here so it is read the same - // way the detail's checks are. statusCheckRollup: rollupStates(node.commits), }), ...(stack === undefined ? {} : { stack }), @@ -951,7 +868,6 @@ function toRow( }; } -/** The head commit's one-word rollup as a nameless check, which `rollupChecksState` reads. */ function rollupStates(commits: { readonly nodes: ReadonlyArray<{ readonly commit: { readonly statusCheckRollup: { readonly state: string } | null }; @@ -987,10 +903,7 @@ export function toStackMembership(raw: { const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; -/** - * The variables of one pull request lookup, or null for a selector or number GitHub cannot - * name: a batch would send it, and GitHub would refuse the whole request rather than its part. - */ +/** Invalid selectors can fail an entire aliased request, not just their own lookup. */ export function pullRequestLookup(changeRequest: { readonly repository: string; readonly number: number; @@ -1022,11 +935,10 @@ export interface GitHubPullRequestSummary { readonly reviewDecision: PullRequestReviewDecision | null; readonly checksState: PullRequestChecksState | null; readonly mergeability: PullRequestMergeability; - /** Null when GitHub says the pull request is in no stack; absent when the read did not ask. */ + /** Null means no stack; absent means not requested. */ readonly stack?: PullRequestStackMembership | null; } -/** The summary a linked thread keeps current, from one slice of the batched read. */ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullRequestSummary { return { number: pr.number, @@ -1044,7 +956,6 @@ export function toPullRequestSummary(pr: SummarizedPullRequest): GitHubPullReque deletions: pr.deletions, changedFiles: pr.changedFiles, reviewDecision: toReviewDecisionWithReviews(pr.reviewDecision, nodesOf(pr.latestReviews)), - // One enum for the head commit, dressed as a single check like the search row's. checksState: rollupChecksState(rollupStates(pr.commits)), mergeability: toMergeability(pr.mergeable), ...(pr.stack === undefined ? {} : { stack: toStackMembership(pr) ?? null }), @@ -1059,7 +970,6 @@ export interface GitHubPullRequestWatchFingerprint { type EditedConnection = WatchedPullRequest["comments"] | WatchedPullRequest["reviews"]; type StateCounts = ReadonlyArray<{ readonly state: string; readonly count: number }> | null; -// An edit only ever moves `lastEditedAt` forward, so the newest one stands for them all. const newestEdit = (connection: EditedConnection) => (connection?.nodes ?? []).reduce( (newest, node) => @@ -1109,7 +1019,6 @@ type CoreRequestedReviewer = NonNullable< >["nodes"] >[number]; -/** Null for a repository or number that names no pull request the viewer can see. */ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullRequestCore | null { const repository = result.repository; const pr = repository?.pullRequest; @@ -1169,7 +1078,6 @@ export function toPullRequestCore(result: PullRequestCore.Result): GitHubPullReq }; } -/** A detail as a REST pull request names it, for the provider's tests and fixtures. */ export function decodePullRequestDetailJson( raw: string, ): Result.Result { @@ -1199,7 +1107,6 @@ export function decodeWorkflowRunsJson( }); } -/** One page of open pull requests on one head branch; an unseen repository is an empty page. */ export function toPullRequestHeads(result: PullRequestHeads.Result): { readonly heads: ReadonlyArray; readonly nextCursor: string | null; @@ -1224,7 +1131,6 @@ export interface GitHubPullRequestActivityPage { readonly nextReviewsCursor: string | null; } -/** One page of the activity read; null for a pull request the viewer cannot see. */ export function toPullRequestActivityPage( result: PullRequestActivity.Result, ): GitHubPullRequestActivityPage | null { @@ -1251,7 +1157,6 @@ export function toPullRequestActivityPage( }; } -/** A check context as the rollup reports it; opaque outside this module. */ export type GitHubCheckContext = CheckFields; function toCheckContexts(nodes: ReadonlyArray): ReadonlyArray { @@ -1262,7 +1167,6 @@ function toCheckContexts(nodes: ReadonlyArray): ReadonlyArray; @@ -1302,11 +1206,7 @@ export interface GitHubReviewThreadComments { string, { readonly additions: number; readonly deletions: number } >; - /** - * Empty wherever the read never happened (an install too old for the field, a degraded page), - * which the caller reads as "keep the `gh pr view` list" rather than as "this pull request has - * no commits". - */ + /** Empty means unread; preserve existing commits. */ readonly commits: ReadonlyArray; readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; } @@ -1355,7 +1255,6 @@ export function reviewThreadConversation( ); } -/** Dismissal reasons by the dismissed review's node id. */ function toDismissalEntries( connection: ReviewThreadsPullRequest["reviewDismissals"], ): Map { @@ -1369,7 +1268,6 @@ function toDismissalEntries( return entries; } -/** One further page of dismissal events, in the shape the thread read's own page carries. */ export function toReviewDismissalsPage(result: ReviewDismissals.Result): { readonly dismissalsByReviewId: ReadonlyMap; readonly nextCursor: string | null; @@ -1427,7 +1325,6 @@ type ReviewThreadsPullRequest = NonNullable< NonNullable["pullRequest"] >; -/** One page of review threads; null for a pull request the viewer cannot see. */ export function toReviewThreadPage(result: ReviewThreads.Result): GitHubReviewThreadPage | null { const pullRequest = result.repository?.pullRequest; if (pullRequest == null) return null; @@ -1444,8 +1341,7 @@ export function toReviewThreadPage(result: ReviewThreads.Result): GitHubReviewTh thread: { id, path, - // Null once the thread's line has left the diff, which is exactly when GitHub reports - // it outdated. Such a thread is listed rather than pinned to a line it no longer has. + // Outdated threads lose their diff line; list them without a line anchor. line: thread.line !== null && thread.line > 0 ? thread.line : null, side: thread.diffSide.toUpperCase() === "LEFT" ? "left" : "right", isResolved: thread.isResolved, @@ -1489,9 +1385,7 @@ export function toReviewThreadPage(result: ReviewThreads.Result): GitHubReviewTh for (const { commit } of nodesOf(pullRequest.commits)) { const oid = trimmed(commit.oid); if (oid === null) continue; - // GitHub measures a merge commit against its first parent, so merging the base into the head - // reports every upstream change as if it belonged to the pull request. There is no useful - // per-commit stat to show for that integration commit without another comparison request. + // Merge stats count upstream changes against the first parent as PR work. if (commit.parents.totalCount <= 1) { commitStats.set(oid, { additions: Math.max(0, commit.additions), @@ -1534,7 +1428,6 @@ export function toReviewThreadPage(result: ReviewThreads.Result): GitHubReviewTh }; } -/** The rest of one thread's comments, in the shape the first page already delivered them. */ export function toReviewThreadCommentsPage(result: ReviewThreadComments.Result): { readonly belongsToPullRequest: boolean; readonly comments: ReadonlyArray; @@ -1583,29 +1476,12 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean { return viewerPermission?.trim().toUpperCase() === "TRIAGE" || toCanWrite(viewerPermission); } -/** - * `assignableUsers` is the list GitHub's own reviewer picker is built from — everyone with access - * to the repository — rather than `collaborators`, which the REST API refuses to anyone without - * push access and which would therefore be empty for exactly the reader most likely to be looking. - * - * The teams a repository could newly be sent to live on the owning organization and need - * `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses fails - * whole, taking the people down with the teams. - */ export interface GitHubBaseComparison { readonly behindBy: number | null; readonly viewerCanUpdate: boolean; } -/** - * The people this pull request may be sent to, with whoever is already on it marked. The author is - * dropped rather than shown as an unusable row: GitHub refuses a review request from the person - * who opened the pull request, so offering them is offering a failure. - * - * Whoever has been asked leads the list even where GitHub does not count them assignable — an - * outside collaborator, an app — because a request that cannot be seen cannot be taken back. - * Null for a repository the viewer cannot see. - */ +/** assignableUsers avoids push/read:org requirements. Keep requested outsiders/apps removable. */ export function toReviewerCandidates( result: ReviewerCandidates.Result, ): PullRequestReviewerCandidateList | null { @@ -1622,7 +1498,7 @@ export function toReviewerCandidates( candidates.set(`${slug === null ? "user" : "team"} ${id}`, { id, kind: slug === null ? "user" : "team", - // The same `org/slug` the reviewer row shows, so taking the request back finds that row. + // Match reviewer rows' org/slug so requests can be withdrawn. login: slug === null ? id : (trimmed("combinedSlug" in raw ? raw.combinedSlug : null) ?? id), name: trimmed("teamName" in raw ? raw.teamName : "name" in raw ? raw.name : null), avatarUrl: trimmed( @@ -1665,12 +1541,7 @@ export function buildReviewerRequest( }; } -/** - * The repository's labels, with the ones already on this pull request marked. A label the pull - * request wears that the repository no longer defines — deleted since, or past the page — leads - * the list anyway, because a label that cannot be seen cannot be taken off. Null for a repository - * the viewer cannot see. - */ +/** Keep applied labels removable even when absent from the repository page. */ export function toLabelCandidates( result: LabelCandidates.Result, ): PullRequestLabelCandidateList | null { @@ -1742,17 +1613,12 @@ export interface GitHubViewerAccess { readonly canTriage: boolean; readonly canUpdate: boolean; readonly didAuthor: boolean; - /** - * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is - * false for a branch that is already current, so it answers "may update, and there is - * something to update" at once. Absent where the comparison was not read. - */ + /** Permitted and update needed; false also covers current branches. Absent if unread. */ readonly canUpdateBranch?: boolean; /** Detail-only readiness; omitted by the lightweight action permission read. */ readonly canMergeNow?: boolean; } -/** Null for a repository the viewer cannot see. */ export function toViewerPermissions( result: ViewerPermissions.Result, ): (GitHubViewerAccess & GitHubRepositoryAccess) | null { @@ -1779,10 +1645,7 @@ export interface GitHubPullRequestFilesPatch { readonly omittedFileStats: ReadonlyArray; } -/** - * The files API returns hunks per file with no `diff --git` header, so the unified patch every - * diff viewer expects is assembled here. - */ +/** The files API omits diff --git headers; assemble them for unified-patch viewers. */ export function decodePullRequestFilesJson( raw: string, ): Result.Result { @@ -1810,9 +1673,7 @@ function toFilesPatch(entries: ReadonlyArray): GitHubPullRequestFilesPa const hunks = value.patch ?? ""; const status = value.status?.trim().toLowerCase(); if (hunks.length === 0) { - // A file with no hunks is still a file that changed: a pure rename has none to give, and - // a binary one has none that can be shown. Both are listed, and only the second is a hole - // in the patch — leaving them out entirely would drop them from the change altogether. + // Hunkless renames are complete; omitted binary changes leave a patch gap. const additions = value.additions ?? 0; const deletions = value.deletions ?? 0; if (additions + deletions > 0) { @@ -1864,7 +1725,6 @@ function toFileViewedState(raw: string): PullRequestFileViewedState { } } -/** One page of viewed state; empty for a pull request the host has nothing to say about. */ export function toPullRequestFilesViewedPage( result: PullRequestFilesViewed.Result, ): GitHubPullRequestFilesViewedPage { diff --git a/packages/source-control-github/src/server/gitHubPullRequests.ts b/packages/source-control-github/src/server/gitHubPullRequests.ts index 104e85b3afce6..4cb96e55a4c3b 100644 --- a/packages/source-control-github/src/server/gitHubPullRequests.ts +++ b/packages/source-control-github/src/server/gitHubPullRequests.ts @@ -37,9 +37,7 @@ const GitHubPullRequestSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), - // gh < 2.47 exports headRepository as {id, name} only; nameWithOwner was - // added later. Both fields stay optional so a version-drifted gh CLI can - // never fail the decode and silently drop the PR from the list. + // gh < 2.47 omits headRepository.nameWithOwner. headRepository: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -79,7 +77,6 @@ function normalizeGitHubPullRequestState(input: { return "open"; } -/** A pull request row, in `gh --json` or GraphQL node shape, as every read reports it. */ export function normalizeGitHubPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedGitHubPullRequestRecord { @@ -118,10 +115,6 @@ export function normalizeGitHubPullRequestRecord( const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); -/** - * Pull request rows in `gh --json` or GraphQL node shape. A row that does not decode is - * skipped, so one malformed pull request cannot hide the rest. - */ function decodeGitHubPullRequestEntries( entries: ReadonlyArray, ): ReadonlyArray { diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index ab0d5975d8440..da97bd96b0a61 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -20,14 +20,7 @@ export function parseFetchRemotes( .map((match) => ({ name: match[1]!, url: match[2]! })); } -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a prompt: the - * remote `gh repo set-default` marked, else the first of upstream, github, origin (in any case), else - * the only remote. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an SSH alias, - * more than one mark, or several remotes with none of those names. - */ +/** gh priority: marked default, upstream/github/origin, sole remote. Null if ambiguous or SSH-aliased. */ export function selectGitHubBaseRepository(input: { readonly remotes: string; readonly resolved: string; @@ -51,8 +44,7 @@ export function selectGitHubBaseRepository(input: { const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); return owner && name && rest.length === 0 ? { owner, name } : null; } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. + // gh ranks these names case-insensitively; a top-ranked tie has no defined winner. const score = (remoteName: string) => ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; const ranked = [...repositories.entries()].toSorted( @@ -64,10 +56,7 @@ export function selectGitHubBaseRepository(input: { : null; } -/** - * `host` is the API host it settled on, which a caller names when no repository was - * found there. GH_REPO is the caller's to check first. - */ +/** Check GH_REPO first; host is available even without a repository. */ export function resolveGitHubRepository(input: { readonly remotes: string; readonly resolved: string; @@ -89,8 +78,7 @@ export function resolveGitHubRepository(input: { host, }); if (selected !== null) return { host, locator: { host, ...selected } }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). + // Match gh's host-filtered priority: upstream, github, origin, then the first remote. const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); const candidates = fetchRemotes .filter( @@ -156,11 +144,7 @@ export function parsePullRequestReference( return { kind: "branch", headSelector: trimmed }; } -/** - * An SSH alias (`git@github-work:owner/repo`) names no API host of its own; it is read - * through `github.com`, which is what such an alias almost always stands for (issue - * #6198). - */ +/** SSH aliases without a domain cannot identify an API host; default to github.com. */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { const remote = parseGitRemoteHost(remoteUrl); if (remote === null) return null; diff --git a/packages/source-control-github/src/server/githubStackActions.test.ts b/packages/source-control-github/src/server/githubStackActions.test.ts index 6ab0c91306384..5f9b96a60e1cd 100644 --- a/packages/source-control-github/src/server/githubStackActions.test.ts +++ b/packages/source-control-github/src/server/githubStackActions.test.ts @@ -14,13 +14,7 @@ import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; -/** - * One request as the fake saw it, flattened to words: the path or document, then each variable - * or body field as `name=value`, so an assertion reads as "this request carried that value". - * A batched GraphQL request is one call, its variables carrying their alias prefixes. - */ type Call = ReadonlyArray; -/** The next answer in the script: a REST body, or for GraphQL the `data` to answer with. */ type Send = (call: Call, kind: "graphql" | "rest") => Effect.Effect; let gitCalls: Array> = []; @@ -45,7 +39,6 @@ const fakeGit = Layer.mergeAll( FileSystem.layerNoop({ makeTempDirectoryScoped: () => Effect.succeed("/tmp/scratch") }), ); -/** GitHub's GraphQL endpoint, answering each request with the next scripted `data`. */ const graphQlHttp = (send: Send) => HttpClient.make((request) => Effect.gen(function* () { @@ -155,7 +148,6 @@ const input = { action: "merge" as const, }; const writable = { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }; -/** One batched branch-access answer: each open layer's pull request, in stack order. */ const accessOf = (...pullRequests: ReadonlyArray) => Object.fromEntries( pullRequests.map((pullRequest, index) => [`_${index}_repository`, { pullRequest }]), @@ -329,11 +321,9 @@ it.effect("rebases the open layers bottom to top in a scratch clone, each onto t gitCalls = []; const api = fake([stack, access]); yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); - // Both layers' access is one aliased read, layer by layer in stack order. expect(api.calls[1]).toEqual( expect.arrayContaining(["_0_number=2", "_1_number=3", "_0_owner=acme", "_0_name=web"]), ); - // GitHub is only read; every change is a git push with a lease on the reviewed head. expect(api.calls.some(isMutation)).toBe(false); const rebases = gitCalls.filter((args) => args[0] === "rebase"); expect(rebases).toEqual([ diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts index 1f2e52b4ff430..f3997a45642a1 100644 --- a/packages/source-control-github/src/server/githubStackActions.ts +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -171,7 +171,6 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* const client = yield* GraphQLClient.make( StackActionsGraphQLGroup.middleware(GitHubGraphQlClient.GitHubGraphQlAuth), ).pipe(Effect.provide(GitHubGraphQlClient.layer)); - // One aliased request checks every layer; `open` is non-empty here. const resolver = GraphQLResolver.aliased(client.PullRequestBranchAccess, { batchKey: GitHubGraphQlClient.batchKey, }).pipe(RequestResolver.batchN(open.length)); @@ -191,8 +190,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (cause) => new GitHubStackResponseInvalidError({ ...identity, cause }), ), ); - // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent - // will make it stale. Check branch write access separately before touching any layer. + // Current layers report viewerCanUpdateBranch=false even if restacking could make them stale. if ( access.some(({ repository }) => { const pr = repository?.pullRequest; @@ -203,8 +201,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* }) ) return yield* new GitHubStackPermissionError({ ...identity }); - // GitHub's own "Rebase stack" has no API, and its per-PR "update branch" replays the old - // copy of every lower layer into the one above it. The cascade moves each layer's own commits. + // GitHub has no stack-rebase API; per-PR updates replay lower layers' old commits. yield* cascadeRebaseStack({ host: input.host, repository: input.repository, diff --git a/packages/source-control-github/src/server/githubStackRebase.ts b/packages/source-control-github/src/server/githubStackRebase.ts index 2b854dec12e40..cf795ab6e1f65 100644 --- a/packages/source-control-github/src/server/githubStackRebase.ts +++ b/packages/source-control-github/src/server/githubStackRebase.ts @@ -59,7 +59,7 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp }), ), ); - // The token rides in a header for this process only: never in the URL, argv, or git config. + // Keep the token in this process's header, outside URLs, argv and git config. const remote = input.remote ?? remoteUrl(input.host, input.repository); const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; const env = { @@ -103,8 +103,7 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp let parentOld = `origin/${input.base}`; let parentNew = `origin/${input.base}`; for (const [index, layer] of input.layers.entries()) { - // The bottom layer's old parent is where it forked from the base; above that it is the - // reviewed head of the layer below, which is exactly the commits this layer must not replay. + // Exclude old parents: the base fork point for the bottom, the reviewed lower head above it. const upstream = index === 0 ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 5d93895315ebe..170395bb7eef5 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -105,6 +105,7 @@ export const definition = defineSourceControl({ ] as const), checkoutCommand: ({ number }) => `gh pr checkout ${number}`, changeRequestCloseCommand: /\bgh\s+(?:pr\s+(?:merge|close)|stack\s+merge)\b/u, + changeRequestCreateCommand: /\bgh\s+pr\s+create\b/u, authorProfileUrl: (login, repositoryUrl) => login.endsWith("[bot]") ? null diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 405c6a5200401..27c0650b133b2 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -269,7 +269,7 @@ export class GitLabCli extends Context.Service< readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - /** Piped to the child's stdin, for payloads that must never appear in argv. */ + /** Keeps sensitive payloads out of argv. */ readonly stdin?: string; readonly maxOutputBytes?: number; }) => Effect.Effect; @@ -418,7 +418,7 @@ const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null = return null; }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const { process } = yield* SourceControlHost.SourceControlHost; diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.legacy.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.legacy.test.ts index 91b649fd98517..cf21ac0c24034 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.legacy.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.legacy.test.ts @@ -27,7 +27,6 @@ const file = { b_mode: "100644", diff: "@@ -1 +1 @@\n-before\n+after\n", }; -/** Supplies JSON at the process boundary while retaining the real CLI adapter and decoder. */ function output(body: unknown, stdoutTruncated = false) { return Effect.succeed({ exitCode: ChildProcessSpawner.ExitCode(0), @@ -37,7 +36,6 @@ function output(body: unknown, stdoutTruncated = false) { stderrTruncated: false, }); } -/** Supplies the failure classification produced by VcsProcess for an unsuccessful glab call. */ function failure(failureKind: "not-found" | "authentication" | "rate-limited" | "command-failed") { return Effect.fail( new VcsProcessExitError({ diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index 5feb1290f5a23..16326f57ffecd 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -189,8 +189,6 @@ layer("GitLabPullRequestCli.layer", (it) => { cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, }); - // GitLab's timestamp filter has no tie-breaker, so an offset is what advances through a - // boundary shared by more rows than one page can hold. const path = argsOfCall(0)[1] ?? ""; expect(path).not.toContain("updated_before="); expect(path).toContain("order_by=updated_at"); @@ -884,8 +882,6 @@ layer("GitLabPullRequestCli.layer", (it) => { head_sha: "head", start_sha: "start", position_type: "text", - // A renamed file is the only case the two differ, and GitLab cannot place a - // position that names the same path on both sides of the rename. old_path: "src/a.ts", new_path: "src/b.ts", old_line: 4, @@ -1006,8 +1002,6 @@ layer("GitLabPullRequestCli.layer", (it) => { requested: true, }); - // GitLab replaces the whole set, so the reviewer already on the merge request has to be - // sent back with the new one or the request would take them off it. expect(argsOfCall(1)).toContain("PUT"); expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5, 9] }); }), diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index 04fe88d03be62..037bf9479558a 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -182,7 +182,7 @@ export type GitLabPullRequestCliError = | GitLabDiffRefsUnavailableError | GitLabViewerUnavailableError; -/** GitLab's own ceiling on `per_page`, so a larger page has to be walked. */ +/** GitLab's `per_page` limit. */ const MAX_PAGE_SIZE = 100; const COMMIT_PAGE_SIZE = 100; /** @@ -502,7 +502,7 @@ function actionArgs( case "merge": return [ "merge", - // glab turns on auto-merge whenever a pipeline is running. The button means merge now. + // glab defaults to auto-merge during a running pipeline. "--auto-merge=false", "--yes", ...(mergeMethod === "squash" ? ["--squash"] : []), @@ -533,7 +533,7 @@ function actionArgs( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const gitlab = yield* GitLabCli.GitLabCli; @@ -551,9 +551,7 @@ export const make = Effect.gen(function* () { "api", input.path, ...(input.method === undefined ? [] : ["--method", input.method]), - // A raw body from stdin: argv is visible in process listings and is echoed back - // inside process-runner failure messages. Unlike `gh`, `glab api --input` sends no - // Content-Type at all, and GitLab answers a bodyless content type with HTTP 415. + // glab --input keeps payloads out of argv/logs but omits GitLab's required Content-Type. ...(input.stdin === undefined ? [] : ["--input", "-", "--header", "Content-Type: application/json"]), @@ -577,16 +575,12 @@ export const make = Effect.gen(function* () { readonly collected: ReadonlyArray; readonly cursorAdvance: number; }): Effect.Effect => { - // A continuation uses GitLab's offset pagination. Its timestamp filter is inclusive and has - // no tie-breaker, so a page where many rows share the boundary would otherwise return the - // same prefix forever. `delivered` is the stable offset the service has already handed over. + // GitLab's timestamp filter can repeat equal-time pages forever; use offsets. const delivered = input.cursor?.delivered ?? 0; const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); const firstPage = Math.floor(delivered / perPage) + 1; const skipOnFirstPage = input.page === firstPage ? delivered % perPage : 0; - // A page made entirely of malformed rows has no item from which the service can build a - // continuation. Bound the walk to the raw span this request asked for rather than recursing - // forever on a host that keeps returning full unusable pages. + // Bound raw pages to stop recursion on entirely malformed results. const lastPage = Math.floor((delivered + input.limit) / perPage) + 1; return api({ cwd: input.cwd, @@ -643,7 +637,7 @@ export const make = Effect.gen(function* () { } const collected = [...input.collected, ...pageItems]; const consumed = Math.max(0, decoded.success.rawCount - skipOnFirstPage); - // Counted before decoding, so a skipped malformed row cannot end paging early. + // Count malformed rows to avoid ending pagination early. const exhausted = decoded.success.rawCount < perPage; if (exhausted) { return Effect.succeed({ @@ -710,9 +704,7 @@ export const make = Effect.gen(function* () { }, }), Effect.flatMap(({ result, legacy }) => { - // A byte-truncated response is a JSON prefix, so this page cannot be read at all. - // Answering with no cursor would call the diff whole while silently dropping this page - // and every one after it, so the read fails and says which page could not be had. + // Returning no cursor for byte-truncated JSON would lose subsequent pages. if (result.stdoutTruncated) { return Effect.fail( new GitLabMergeRequestReadError({ @@ -743,8 +735,7 @@ export const make = Effect.gen(function* () { // rather than pointing the reader back at the page it just read. const morePages = !legacy && decoded.success.rawCount >= diffPageSize(input.commit); return Effect.succeed({ - // The slice ends on a newline, so a file GitLab gave a header and no hunks for does - // not run into the first line of the next slice. + // Terminate slices so a hunkless header cannot run into the next slice. patch: patch.length === 0 ? patch : patch.replace(/\n?$/, "\n"), truncated: decoded.success.truncated, nextCursor: morePages ? String(input.page + 1) : null, @@ -827,8 +818,7 @@ export const make = Effect.gen(function* () { ); } const collected = [...input.collected, ...decoded.success.threads]; - // The raw count again: this endpoint returns the plain notes too, so a full page of - // those is not the end of the positioned ones. + // Plain notes count toward the page size even when no positioned threads survive decoding. if (decoded.success.rawCount < MAX_PAGE_SIZE) { return Effect.succeed({ threads: collected, truncated: false }); } @@ -1034,10 +1024,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * GitLab charges the blobs query by how many paths it is handed, and its connection hands back - * one page. A hundred at a time keeps each request inside both. - */ + // GitLab charges by path count and returns one connection page; respect both limits. const BLOB_PATHS_PER_REQUEST = 100; const blobsAt = (input: { @@ -1097,10 +1084,7 @@ export const make = Effect.gen(function* () { Effect.map((pages) => { const revisions = new Map(); for (const { paths, page } of pages) { - // A batch GitLab did not answer says nothing about its paths, so they are left - // out and the caller reads them as versions it could not learn, which leaves - // the marks on them alone. Filling them in as removed would report every file - // a reader has cleared as changed over a project the token cannot see. + // Unanswered paths are unknown; omit them to preserve viewed marks. if (page === null) continue; for (const [path, oid] of page) revisions.set(path, oid); for (const path of paths) { @@ -1340,8 +1324,7 @@ export const make = Effect.gen(function* () { const author = mergeRequest.author?.login; const requested = new Set(mergeRequest.reviewRequestLogins); return { - // The author is dropped rather than shown unusable: GitLab refuses to make the person - // who opened a merge request its reviewer. + // GitLab forbids requesting a review from the MR author. candidates: users.candidates.flatMap((candidate) => candidate.login === author ? [] @@ -1355,9 +1338,7 @@ export const make = Effect.gen(function* () { setReviewerRequest: (input) => mergeRequestDetail(input).pipe( Effect.flatMap((mergeRequest) => { - // GitLab has no endpoint that adds or removes one reviewer: `reviewer_ids` replaces the - // whole set, so the set that is already there is read first and the change applied to - // it. + // GitLab replaces reviewer_ids wholesale. const ids = new Set(mergeRequest.reviewerIds); for (const reviewer of input.reviewers) { const id = Number(reviewer.id); @@ -1410,9 +1391,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, method: "PUT", - // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and - // clears what it is sent empty — so a title corrected on its own must carry no - // description at all. + // Omitted fields remain unchanged; empty values clear them. stdin: JSON.stringify({ ...(input.title === undefined ? {} : { title: input.title }), ...(input.description === undefined ? {} : { description: input.description }), @@ -1424,8 +1403,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes`, method: "POST", - // A JSON body rather than a `--raw-field`: glab coerces a field that reads as a - // literal `true` or a number, and a comment body is text either way. + // JSON avoids glab's coercion of numeric/boolean comment bodies. stdin: JSON.stringify({ body: input.body }), }).pipe(Effect.asVoid), diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 424295c48fab8..a5bb87745f918 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -21,20 +21,17 @@ const CAPABILITIES: PullRequestCapabilities = { comment: true, actions: [...definition.changeRequestActions], mergeMethods: ["merge", "squash", "rebase"], - // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing - // that merges the target back in the way GitHub's update button can. + // GitLab has no merge-target-into-source operation. updateMethods: ["rebase"], search: true, reactions: true, - // GitLab keeps a reader's viewed files in one browser's local storage, where nothing outside - // that browser can read or write them. + // GitLab's viewed marks live in browser-local storage. viewedFiles: "environment", review: { inlineComment: true, reply: true, resolve: true, - // No "changes requested": GitLab has approval and unresolved discussions, and nothing that - // says a merge request has been reviewed and rejected. + // GitLab has no requested-changes verdict. verdicts: ["comment", "approve"], }, reviewers: { request: true, listCandidates: true }, @@ -59,11 +56,7 @@ const MERGE_ACTIONS: ReadonlySet = new Set([ "disable-auto-merge", ]); -/** - * GitLab's REST API reports the viewer's role on the project but never whether they opened this - * merge request — and its author may close it, reopen it and move it in and out of draft - * whatever their role, just as the author of a note may resolve the discussion it started. - */ +/** Authors may close/reopen/change draft state regardless of role; REST omits authorship. */ export function gitLabViewerPermissions(input: { readonly viewerCanMerge: boolean; }): PullRequestViewerPermissions { @@ -163,8 +156,7 @@ export const make = Effect.gen(function* () { ...mergeRequest, mergeCapabilities, viewerPermissions: gitLabViewerPermissions(mergeRequest), - // A GitLab too old to count the divergence says nothing here rather than "up to - // date": the banner is worth missing, and a wrong all-clear is not worth showing. + // Missing divergence counts mean unknown, not up to date. baseComparison: mergeRequest.divergedCommits === undefined ? "unknown" @@ -220,16 +212,13 @@ export const make = Effect.gen(function* () { getDiffFileContents: (input) => cli.getMergeRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))), - // GitLab's own local-storage marks are keyed on the blob id too, so this stales at the same - // moment its web UI would. getFileRevisions: (input) => cli.getFileRevisions(input).pipe( Effect.mapError(fail("getFileRevisions")), Effect.map((revisions) => ({ revisions })), ), - // Users only: GitLab requests a review of a person, and the groups that can stand in for one - // appear in approval rules rather than in a merge request's reviewers. + // MR reviewers are users; groups belong to approval rules. listReviewerCandidates: (input) => cli .listReviewerCandidates({ diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 81cf7ec477c06..d614db1218a37 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -147,7 +147,7 @@ export const discovery = { executable: "glab", versionArgs: ["--version"], authArgs: ["auth", "status"], - // glab matches its configured hosts by exact name, so ask with the remote's own spelling. + // glab host matching requires the remote's exact spelling. remoteRefinementArgs: ({ provider, remoteUrl }) => { const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/]+)/iu.exec(remoteUrl.trim())?.[1]; const remoteHost = authority diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts index aba89ac5670b8..92f9ed19fe4dc 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.test.ts @@ -159,7 +159,6 @@ describe("decodeMergeRequestDetailJson", () => { const sha = "afce4da4d5fe7d2fd0647b5b864526ea0a2827cb"; expect(headSha({ sha })).toBe(sha); - // A new merge request can report its head before GitLab has computed `diff_refs`. expect(headSha({ sha, diff_refs: null })).toBe(sha); expect(headSha({ sha, merge_commit_sha: "0".repeat(40) })).toBe(sha); expect(headSha({})).toBeUndefined(); diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 4edfbbdab185d..c87c450c22016 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -22,10 +22,7 @@ import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -/** - * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that - * adds a pipeline status or a merge status must not fail the whole payload. - */ +/** Unknown pipeline or merge status values must not fail the whole payload. */ const RawUserSchema = Schema.Struct({ id: Schema.optional(Schema.Int), username: Schema.String, @@ -59,7 +56,7 @@ const RawMergeRequestSchema = Schema.Struct({ closed_at: Schema.optional(Schema.NullOr(Schema.String)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), - // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. + // GitLab reports "1000+" past its counting limit. changes_count: Schema.optional(Schema.NullOr(Schema.String)), head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), user: Schema.optional( @@ -69,11 +66,7 @@ const RawMergeRequestSchema = Schema.Struct({ auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), squash_on_merge: Schema.optional(Schema.NullOr(Schema.Boolean)), squash: Schema.optional(Schema.NullOr(Schema.Boolean)), - /** - * It costs a walk of the two branches, so GitLab withholds it unless - * `include_diverged_commits_count` asks for it, and answers it only for a single merge - * request — a list never carries it, however it is asked for. - */ + /** Only detail reads with include_diverged_commits_count return this expensive count. */ diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), }); @@ -201,10 +194,7 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly reviewerIds: ReadonlyArray; readonly autoMergeEnabled?: boolean; readonly autoMergeMethod?: PullRequestMergeMethod; - /** - * Absent where GitLab did not count, which is not the same as a branch that has nothing behind - * it: an install too old to answer must not be read as saying the branch is current. - */ + /** Absent means not counted, not up to date. */ readonly divergedCommits?: number; readonly headSha?: string; } @@ -373,7 +363,7 @@ type DecodeFailure = Cause.Cause; export interface GitLabProjectUsers { readonly candidates: ReadonlyArray; - /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ + /** Count before decoding to avoid ending pagination on skipped rows. */ readonly rawCount: number; } @@ -667,7 +657,6 @@ export function decodeMergeRequestChangesJson( }); } -/** Builds file patches, retaining raw counts and recording files whose contents are missing. */ function mergeRequestPatch(entries: ReadonlyArray): GitLabMergeRequestPatch { const sections: string[] = []; let truncated = false; @@ -941,12 +930,7 @@ const RawRepositoryBlobsSchema = Schema.Struct({ const decodeRepositoryBlobs = decodeJsonResult(RawRepositoryBlobsSchema); -/** - * Blob ids by path, or null where GitLab did not answer the query at all (a project the token - * cannot see, or a repository with no blobs connection). That case must be told apart from an - * empty answer: read as "the revision has none of these files", it would report every cleared - * file as changed again. - */ +/** Null means unknown, not deleted; preserve viewed marks. */ export function decodeRepositoryBlobsJson( raw: string, ): Result.Result | null, DecodeFailure> { @@ -958,9 +942,7 @@ export function decodeRepositoryBlobsJson( if (nodes === undefined || nodes === null) return Result.succeed(null); const blobs = new Map(); for (const node of nodes) { - // Not trimmed, unlike everything else read out of this payload: a leading or trailing space - // is a legal part of a file's name, and trimming it would key this map under a name the - // caller's asked-for path never matches. + // Preserve path whitespace so blob-map keys match the caller's exact filenames. const path = node?.path; const oid = trimmed(node?.oid); if (path === undefined || path === null || path.length === 0 || oid === null) continue; diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 14ef165f03b2c..1a3c417641ce4 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -53,6 +53,7 @@ export const definition = defineSourceControl({ ] as const), checkoutCommand: ({ number }) => `glab mr checkout ${number}`, changeRequestCloseCommand: /\bglab\s+mr\s+(?:merge|close)\b/u, + changeRequestCreateCommand: /\bglab\s+mr\s+create\b/u, authorProfileUrl: () => null, reviewSummaryRequired: () => false, checkoutCommandArgument: (input) => CHECKOUT_COMMAND.exec(input)?.[1]?.trim() ?? null, diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index be721f5797a01..603803ea7fcf6 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -51,12 +51,9 @@ const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const TUNNEL_STDERR_TAIL_CHARS = 16_384; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; -// A cold archive launch also downloads and unpacks a ~70 MB release archive -// and may wait on another installer's lock. The budgets nest: the checksum -// file is tiny and the archive download is bounded; a waiter outlasts both -// downloads plus extraction so it can reuse the result; and the SSH command -// outlasts an install (own or waited-for) plus readiness, with slack for -// verification and extraction, which have no timeout of their own. +// Cold archive launches download and extract ~70 MB and may wait on an install lock. +// Lock waits must outlast both downloads and extraction; SSH must outlast install or wait plus readiness. +// Leave slack for verification and extraction, which have no timeout. const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30; const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240; const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360; diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.6.1.patch similarity index 90% rename from patches/@pierre%2Fdiffs@1.5.2.patch rename to patches/@pierre%2Fdiffs@1.6.1.patch index a944c6355a2c7..dba0acc7eb623 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.6.1.patch @@ -1,5 +1,5 @@ diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts -index cc32d28..3a46bf4 100644 +index 234ec4baa4..274ff5e32b 100644 --- a/dist/components/CodeView.d.ts +++ b/dist/components/CodeView.d.ts @@ -158,6 +158,12 @@ interface CodeViewOptions extends CodeViewPassThroughOptions @@ -15,7 +15,7 @@ index cc32d28..3a46bf4 100644 /** Render a non-virtualized element at the very start of the scroll content, * before the first item. It is always rendered and scrolls with the content. * Return the same element across calls and mutate it in place to update; -@@ -268,6 +274,13 @@ declare class CodeView { +@@ -269,6 +275,13 @@ declare class CodeView { getSelectedLines(): CodeViewLineSelection | null; clearSelectedLines(options?: SelectionWriteOptions): void; getItem(itemId: string): CodeViewItem | undefined; @@ -30,10 +30,10 @@ index cc32d28..3a46bf4 100644 * Returns the editor while this item is being edited. Partial diffs get an * editor after their files load; scrolling away or collapsing keeps it. diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js -index 140f94f..96fc774 100644 +index ee5c91c5ab..7d0a2901e4 100644 --- a/dist/components/CodeView.js +++ b/dist/components/CodeView.js -@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js"; +@@ -18,6 +18,7 @@ import { resolvePreferredHighlighter } from "../utils/resolvePreferredHighlighte import { roundToDevicePixel } from "../utils/roundToDevicePixel.js"; import { VirtualizedFile } from "./VirtualizedFile.js"; import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js"; @@ -41,7 +41,7 @@ index 140f94f..96fc774 100644 //#region src/components/CodeView.ts const CODE_VIEW_DIFF_OPTION_KEYS = [ "theme", -@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) { +@@ -116,6 +117,8 @@ function defineItemOption(target, key, get) { } const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120; const SUB_PIXEL_TOLERANCE = 1; @@ -50,16 +50,16 @@ index 140f94f..96fc774 100644 const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override"; function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) { return { -@@ -211,6 +214,8 @@ var CodeView = class CodeView { +@@ -213,6 +216,8 @@ var CodeView = class CodeView { isReadySubscription; pendingHighlighterTheme; - isContainerManaged; + isComponentManaged; + search = new CodeViewSearch(this); + pendingSearchReveal; - constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) { + slotSnapshotStale = false; + constructor(options = { theme: DEFAULT_THEMES }, workerManager, isComponentManaged = false) { this.options = options; - this.computeMetricsCache(options.itemMetrics); -@@ -392,6 +397,7 @@ var CodeView = class CodeView { +@@ -395,6 +400,7 @@ var CodeView = class CodeView { this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true }); this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true }); this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true }); @@ -67,15 +67,15 @@ index 140f94f..96fc774 100644 this.resizeObserver.observe(this.root); this.render(true); window.__INSTANCE = this; -@@ -464,6 +470,7 @@ var CodeView = class CodeView { +@@ -467,6 +473,7 @@ var CodeView = class CodeView { this.heightDirty = true; this.resetRenderState(); - if (!this.isContainerManaged) this.flushSlotCoordinator(); + if (!this.isComponentManaged) this.flushSlotCoordinator(); + this.search.refresh(); if (failed) throw failure; } cleanUp() { -@@ -485,6 +492,8 @@ var CodeView = class CodeView { +@@ -488,6 +495,8 @@ var CodeView = class CodeView { this.root?.removeEventListener("touchstart", this.clearPendingScroll); this.root?.removeEventListener("pointerdown", this.clearPendingScroll); this.root?.removeEventListener("keydown", this.clearPendingScroll); @@ -84,7 +84,7 @@ index 140f94f..96fc774 100644 this.root?.style.removeProperty("overflow-anchor"); this.container?.remove(); this.stickyOffset.remove(); -@@ -626,6 +635,137 @@ var CodeView = class CodeView { +@@ -629,6 +638,137 @@ var CodeView = class CodeView { getItem(itemId) { return this.idToItem.get(itemId)?.item; } @@ -222,7 +222,7 @@ index 140f94f..96fc774 100644 /** * Returns the editor while this item is being edited. Partial diffs get an * editor after their files load; scrolling away or collapsing keeps it. -@@ -645,6 +785,7 @@ var CodeView = class CodeView { +@@ -648,6 +788,7 @@ var CodeView = class CodeView { this.render(); this.syncItemEditors(); this.syncSelection(); @@ -230,7 +230,7 @@ index 140f94f..96fc774 100644 return true; } updateItemId(oldId, newId) { -@@ -678,6 +819,7 @@ var CodeView = class CodeView { +@@ -681,6 +822,7 @@ var CodeView = class CodeView { this.renamePendingScrollTarget(oldId, newId); this.renamePendingLayoutAnchor(oldId, newId); this.render(); @@ -238,7 +238,7 @@ index 140f94f..96fc774 100644 return true; } addItem(input) { -@@ -687,6 +829,7 @@ var CodeView = class CodeView { +@@ -690,6 +832,7 @@ var CodeView = class CodeView { this.appendItemsInternal(inputs); this.syncItemEditors(); this.syncSelection(); @@ -246,7 +246,7 @@ index 140f94f..96fc774 100644 } removeItem(itemId) { const item = this.idToItem.get(itemId); -@@ -709,6 +852,7 @@ var CodeView = class CodeView { +@@ -712,6 +855,7 @@ var CodeView = class CodeView { for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp(); } this.syncSelection(); @@ -254,7 +254,7 @@ index 140f94f..96fc774 100644 } /** * Append new records to the viewer while preserving existing layout state. -@@ -1330,6 +1474,10 @@ var CodeView = class CodeView { +@@ -1334,6 +1478,10 @@ var CodeView = class CodeView { item.item = nextItem; item.version = nextItem.version; item.renderedOptionsRevision = -1; @@ -265,7 +265,7 @@ index 140f94f..96fc774 100644 return true; } getMaxScrollTopForHeight(scrollHeight) { -@@ -1710,6 +1858,10 @@ var CodeView = class CodeView { +@@ -1714,6 +1862,10 @@ var CodeView = class CodeView { this.updateStickyPositioning(); this.validateStickyContainerHeight(); this.fixContainerFocus(); @@ -276,7 +276,7 @@ index 140f94f..96fc774 100644 if (fitPerfectly || this.scrollAnimation != null) this.render(); }; flushManagers(updatedItems) { -@@ -1804,7 +1956,8 @@ var CodeView = class CodeView { +@@ -1808,7 +1960,8 @@ var CodeView = class CodeView { this.notifyScroll(); this.render(); }; @@ -286,7 +286,7 @@ index 140f94f..96fc774 100644 this.pendingScrollTarget = void 0; this.pendingLayoutAnchor = void 0; this.scrollAnimation = void 0; -@@ -2066,7 +2219,12 @@ var CodeView = class CodeView { +@@ -2071,7 +2224,12 @@ var CodeView = class CodeView { const item = this.items[index]; if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index"); item.top = runningTop; @@ -300,7 +300,7 @@ index 140f94f..96fc774 100644 else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []); runningTop += item.height; if (index < this.items.length - 1) runningTop += layout.gap; -@@ -2086,6 +2244,27 @@ var CodeView = class CodeView { +@@ -2091,6 +2249,27 @@ var CodeView = class CodeView { return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight; } }; @@ -330,7 +330,7 @@ index 140f94f..96fc774 100644 if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []); diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js new file mode 100644 -index 0000000..29370ad +index 0000000000..29370adf88 --- /dev/null +++ b/dist/components/CodeViewSearch.js @@ -0,0 +1,360 @@ @@ -695,22 +695,32 @@ index 0000000..29370ad + +//# sourceMappingURL=CodeViewSearch.js.map diff --git a/dist/components/FileDiff.js b/dist/components/FileDiff.js -index 6f66bd3..3125fac 100644 +index 12f4f8949f..e33a5ed319 100644 --- a/dist/components/FileDiff.js +++ b/dist/components/FileDiff.js -@@ -1,5 +1,6 @@ --import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_RENDER_RANGE, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; -+import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; +@@ -1,6 +1,7 @@ +-import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_LINE_ANNOTATIONS, EMPTY_RENDER_RANGE, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; ++import { CUSTOM_HEADER_SLOT_ID, DEFAULT_THEMES, EMPTY_LINE_ANNOTATIONS, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID } from "../constants.js"; + import { HighlighterDisposedError } from "../highlighter/highlighterType.js"; import { getThemes } from "../utils/getThemes.js"; +import { getHighlighterThemeStyles } from "../utils/getHighlighterThemeStyles.js"; import { getHighlighterIfLoaded, getSharedHighlighter } from "../highlighter/shared_highlighter.js"; import { dequeueRender, queueRender } from "../managers/UniversalRenderingManager.js"; import { areThemesEqual } from "../utils/areThemesEqual.js"; -@@ -691,11 +692,20 @@ var FileDiff = class { +@@ -724,7 +725,7 @@ var FileDiff = class { + if (expandUnchanged) this.loadFilesIfNecessary(); + this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); + this.syncInteractionOptions(); +- this.prepareRenderData(this.hunksRenderer.getDiffForNextRender(workingDiff)); ++ this.prepareRenderData(collapsed ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff)); + const { disableErrorHandling = false, disableFileHeader = false } = this.options; + if (disableFileHeader) { + if (this.headerElement != null) { +@@ -740,11 +741,22 @@ var FileDiff = class { this.removeRenderedCode(); this.clearAuxiliaryNodes(); try { -- const hunksResult = this.hunksRenderer.renderDiff(latestDiff, EMPTY_RENDER_RANGE); +- const hunksResult = this.hunksRenderer.renderDiff(workingDiff, EMPTY_RENDER_RANGE); - if (hunksResult != null) this.applyThemeState(fileContainer, hunksResult.themeStyles, themeType, hunksResult.baseThemeType); - if (hunksResult?.headerElement != null) this.applyHeaderToDOM(hunksResult.headerElement, fileContainer, hunksResult.fileDiff); + // An empty render range still highlights the full diff. Headers only need the theme. @@ -720,24 +730,26 @@ index 6f66bd3..3125fac 100644 + else getSharedHighlighter({ + themes: getThemes(theme), + langs: ["text"], -+ preferredHighlighter: this.workerManager?.getPreferredHighlighter() ?? this.options.preferredHighlighter ++ preferredHighlighter: resolvePreferredHighlighter(this.workerManager, this.options) + }).then(() => { + if (this.enabled && this.fileContainer === fileContainer && this.options.collapsed && areThemesEqual(theme, this.getTheme())) this.rerender(); -+ }).catch((error) => console.error(error)); -+ if (!disableFileHeader) this.applyHeaderToDOM(this.hunksRenderer.renderHeader(latestDiff), fileContainer, latestDiff); ++ }).catch((error) => { ++ if (!(error instanceof HighlighterDisposedError)) console.error(error); ++ }); ++ if (!disableFileHeader) this.applyHeaderToDOM(this.hunksRenderer.renderHeader(workingDiff), fileContainer, workingDiff); this.renderSeparators([]); -- this.renderedDiff = hunksResult?.fileDiff ?? latestDiff; -+ this.renderedDiff = latestDiff; +- this.adoptRenderData(hunksResult?.fileDiff ?? workingDiff); ++ this.adoptRenderData(workingDiff); this.injectUnsafeCSS(); } catch (error) { if (disableErrorHandling) throw error; diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts -index 5e68e35..79dcd88 100644 +index 90ef575932..0fd262cbb7 100644 --- a/dist/components/VirtualizedFile.d.ts +++ b/dist/components/VirtualizedFile.d.ts -@@ -49,7 +49,7 @@ declare class VirtualizedFile extend - setVisibility(visible: boolean): void; +@@ -50,7 +50,7 @@ declare class VirtualizedFile extend rerender(): void; + protected get isComponentManaged(): boolean; syncGhostTextRows(): void; - applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean): void; + applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation[], shouldUpdateBuffer?: boolean, startLine?: number): void; @@ -745,7 +757,7 @@ index 5e68e35..79dcd88 100644 protected finalizeRender(): void; private updatePendingRender; diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js -index d1f57cd..5908ad7 100644 +index 95d4ca8fdf..24a980672d 100644 --- a/dist/components/VirtualizedFile.js +++ b/dist/components/VirtualizedFile.js @@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { @@ -756,8 +768,8 @@ index d1f57cd..5908ad7 100644 ghostTextRows: NO_GHOST_TEXT_ROWS }; pendingRender; -@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { - super(options, workerManager, isContainerManaged); +@@ -34,6 +35,8 @@ var VirtualizedFile = class extends File { + super(options, workerManager, requestOwnerRender); this.virtualizer = virtualizer; this.metrics = metrics; + const simpleVirtualizer = this.getSimpleVirtualizer(); @@ -765,7 +777,7 @@ index d1f57cd..5908ad7 100644 } setMetrics(metrics, force = false) { const nextMetrics = computeVirtualFileMetrics(metrics); -@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { +@@ -85,10 +88,12 @@ var VirtualizedFile = class extends File { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } @@ -781,7 +793,7 @@ index d1f57cd..5908ad7 100644 if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; -@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { +@@ -134,6 +139,13 @@ var VirtualizedFile = class extends File { if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; @@ -795,7 +807,7 @@ index d1f57cd..5908ad7 100644 if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { -@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { +@@ -366,12 +378,12 @@ var VirtualizedFile = class extends File { codeView.instanceChanged(this, true); } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); } @@ -811,29 +823,29 @@ index d1f57cd..5908ad7 100644 if (!this.isSimpleMode()) this.computeApproximateSize(true); else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { diff --git a/dist/components/VirtualizedFileDiff.js b/dist/components/VirtualizedFileDiff.js -index 96f970f..1bb59c6 100644 +index eb34dd4a77..821bb0ab11 100644 --- a/dist/components/VirtualizedFileDiff.js +++ b/dist/components/VirtualizedFileDiff.js -@@ -552,7 +552,7 @@ var VirtualizedFileDiff = class extends FileDiff { +@@ -551,7 +551,7 @@ var VirtualizedFileDiff = class extends FileDiff { rerender() { - if (!this.enabled || this.fileDiff == null && this.additionFile == null && this.deletionFile == null) return; - const latestDiff = this.getLatestDiff(); -- const nextRenderDiff = latestDiff == null ? void 0 : this.hunksRenderer.getDiffForNextRender(latestDiff); -+ const nextRenderDiff = latestDiff == null || this.options.collapsed === true ? latestDiff : this.hunksRenderer.getDiffForNextRender(latestDiff); + if (!this.enabled || this.getInputDiff() == null && this.additionFile == null && this.deletionFile == null) return; + const workingDiff = this.getWorkingDiff(); +- const nextRenderDiff = workingDiff == null ? void 0 : this.hunksRenderer.getDiffForNextRender(workingDiff); ++ const nextRenderDiff = workingDiff == null || this.options.collapsed === true ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff); if (this.isSimpleMode()) this.pendingRender = void 0; this.forceRenderOverride = true; - this.virtualizer.instanceChanged(this, !areDiffTargetsEqual(this.getRenderedDiff(), nextRenderDiff)); -@@ -724,7 +724,7 @@ var VirtualizedFileDiff = class extends FileDiff { + if (this.isSimpleMode() && this.requestRender()) return; +@@ -731,7 +731,7 @@ var VirtualizedFileDiff = class extends FileDiff { const previousRenderedDiff = this.getRenderedDiff(); const { pendingRender } = this; const previousLayoutDiff = pendingRender?.diff ?? previousRenderedDiff; -- const pendingRenderDiff = this.hunksRenderer.getDiffForNextRender(latestDiff); -+ const pendingRenderDiff = this.options.collapsed === true ? latestDiff : this.hunksRenderer.getDiffForNextRender(latestDiff); +- const pendingRenderDiff = this.hunksRenderer.getDiffForNextRender(workingDiff); ++ const pendingRenderDiff = this.options.collapsed === true ? workingDiff : this.hunksRenderer.getDiffForNextRender(workingDiff); this.pendingRender = { - latestDiff, + workingDiff, diff: pendingRenderDiff diff --git a/dist/components/web-components.js b/dist/components/web-components.js -index 6e7b486..5b7290f 100644 +index 6e7b486a02..5b7290f181 100644 --- a/dist/components/web-components.js +++ b/dist/components/web-components.js @@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js"; @@ -867,10 +879,10 @@ index 6e7b486..5b7290f 100644 connectedCallback() { const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" }); diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index 8f04a6f..c203023 100644 +index bf17cea32f..671e77b8cd 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -1899,7 +1899,11 @@ var Editor = class { +@@ -1903,7 +1903,11 @@ var Editor = class { changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, documentLineCount: textDocument.lineCount }); @@ -884,7 +896,7 @@ index 8f04a6f..c203023 100644 if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts -index 9542849..1c1a51f 100644 +index 9542849786..1c1a51f56d 100644 --- a/dist/editor/searchPanel.d.ts +++ b/dist/editor/searchPanel.d.ts @@ -15,6 +15,8 @@ interface SearchPanelOptions { @@ -897,7 +909,7 @@ index 9542849..1c1a51f 100644 scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; applyReplace: (edits: ResolvedTextEdit[]) => void; diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js -index df1ba63..676751b 100644 +index df1ba637c2..676751bb0e 100644 --- a/dist/editor/searchPanel.js +++ b/dist/editor/searchPanel.js @@ -12,7 +12,7 @@ var SearchPanelWidget = class { @@ -919,7 +931,7 @@ index df1ba63..676751b 100644 this.#inputElement.select(); }; diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts -index 3efafc4..9e5b751 100644 +index 3efafc4132..9e5b7511f9 100644 --- a/dist/managers/ResizeManager.d.ts +++ b/dist/managers/ResizeManager.d.ts @@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions { @@ -932,7 +944,7 @@ index 3efafc4..9e5b751 100644 private static managersByElement; private static getResizeObserver; diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js -index 9724cb1..14145d8 100644 +index 9724cb1e34..14145d8329 100644 --- a/dist/managers/ResizeManager.js +++ b/dist/managers/ResizeManager.js @@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager { @@ -952,10 +964,10 @@ index 9724cb1..14145d8 100644 } applyAnnotationUpdates(annotationUpdates) { diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts -index e78e713..642b235 100644 +index 12e12c50cd..684c7a89fa 100644 --- a/dist/react/CodeView.d.ts +++ b/dist/react/CodeView.d.ts -@@ -56,6 +56,8 @@ interface CodeViewBaseProps { +@@ -57,6 +57,8 @@ interface CodeViewBaseProps { * is frozen; re-key the accepted value in place before accepting. */ onItemEditComplete?: CodeViewItemEditCompleteHandler; @@ -965,7 +977,7 @@ index e78e713..642b235 100644 renderHeaderPrefix?(item: CodeViewItem): ReactNode; renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js -index 84c7915..d1930bf 100644 +index 4dfed6bd0a..ac35a5a455 100644 --- a/dist/react/CodeView.js +++ b/dist/react/CodeView.js @@ -24,7 +24,7 @@ function createDefaultCache(controlled) { @@ -1030,7 +1042,7 @@ index 84c7915..d1930bf 100644 if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender; if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender; diff --git a/dist/utils/createFileHeaderElement.js b/dist/utils/createFileHeaderElement.js -index 39fe30e..600442e 100644 +index 39fe30e47b..600442e3eb 100644 --- a/dist/utils/createFileHeaderElement.js +++ b/dist/utils/createFileHeaderElement.js @@ -73,12 +73,12 @@ function createMetadataElement(fileDiff) { @@ -1049,10 +1061,10 @@ index 39fe30e..600442e 100644 })); } diff --git a/package.json b/package.json -index d9dc7b5..3c37bae 100644 +index 0cc43c4a7d..60b7d17d61 100644 --- a/package.json +++ b/package.json -@@ -58,6 +58,18 @@ +@@ -60,6 +60,18 @@ "./worker/worker-portable.js": { "types": "./dist/worker/worker-portable.d.ts", "import": "./dist/worker/worker-portable.js" diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 5e2071dca0b62..a96e172c09886 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -343,10 +343,21 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..475a99b2a3b1800c9d3e3e5f77a64f55 + } } diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift -index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 +index bb6bb2b7ad56135089f267587c974b166760539d..5675067ecb2db0239b74c66ff2e095471a721699 100644 --- a/ios/Shared/RCTMenuItem.swift +++ b/ios/Shared/RCTMenuItem.swift -@@ -103,10 +103,18 @@ class RCTMenuAction { +@@ -27,7 +27,9 @@ class RCTMenuAction { + self.identifier = UIAction.Identifier(rawValue: identifier as String); + } + +- if let image = details["image"] as? NSString { ++ // Fabric sends an absent image as "", and every asset lookup for "" logs a ++ // CoreUI fault with a backtrace. ++ if let image = details["image"] as? NSString, image.length > 0 { + self.image = UIImage(systemName: image as String); + if self.image === nil { + self.image = UIImage(named: image as String) +@@ -103,10 +105,18 @@ class RCTMenuAction { subMenuActions.append(subaction.createUIMenuElement(handler)) } var menu: UIMenu; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04173c395e7be..6fae129a08fc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ catalogs: '@noble/hashes': specifier: 1.8.0 version: 1.8.0 + '@pierre/highlights': + specifier: 0.2.0 + version: 0.2.0 '@types/plist': specifier: 3.0.5 version: 3.0.5 @@ -261,7 +264,7 @@ overrides: expo-widgets: 58.0.20 '@ff-labs/fff-node': 0.11.0 '@legendapp/list': 3.3.5 - '@pierre/diffs': 1.5.2 + '@pierre/diffs': 1.6.1 '@react-native-ai/apple': 0.12.0 '@react-native-menu/menu': 2.0.0 '@react-navigation/native-stack': 7.21.0 @@ -334,13 +337,13 @@ overrides: '@types/ws@^8': 8.18.2 undici@^8: 8.11.2 ws@^8: 8.22.0 - vite: npm:@voidzero-dev/vite-plus-core@1.3.0 - vite-plus: 1.3.0 + vite: npm:@voidzero-dev/vite-plus-core@1.4.0 + vite-plus: 1.4.0 yaml: ^2.9.0 packageExtensionsChecksum: sha256-EowbsmlBFaoO0Ypufw901RnvdyjKG5dcdk2FRbc7Dr0= -pnpmfileChecksum: sha256-YzTn4CWqI4l/Czk2VAdpGztl9kv2T+Fnt/Gw1mJ7Lu0= +pnpmfileChecksum: sha256-STsbxG7+RxOFI611PrDEj1TonJgqKKllY9hYCFMLYiU= patchedDependencies: '@clerk/expo-native-components@0.4.0': 7a808b92da8460750b85818552e13be3fb73d111691c7db76965970351e124e4 @@ -351,9 +354,9 @@ patchedDependencies: '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.3.5': 011a862a7116ec5b5f4bba17d37bc58c9b5a8f64a6a9f527345f316ce9c96e63 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 - '@pierre/diffs@1.5.2': 2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc + '@pierre/diffs@1.6.1': 4645364ddf113e48e8827f3b4966b3b1ff745317011e5a3d904e2d8672dee7dc '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d - '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba + '@react-native-menu/menu@2.0.0': 910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6 '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd app-builder-lib@26.16.1: a28c2dfcfc2b90d6c6247e199e6537391db9ecd941854c650910d607442512d3 @@ -394,8 +397,8 @@ importers: specifier: 'catalog:' version: '@tsc-rs/typescript@0.8.0' vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -479,8 +482,8 @@ importers: specifier: 4.3.3 version: 4.3.3 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -534,8 +537,11 @@ importers: specifier: 'catalog:' version: 1.9.1 '@pierre/diffs': - specifier: 1.5.2 - version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.6.1 + version: 1.6.1(patch_hash=4645364ddf113e48e8827f3b4966b3b1ff745317011e5a3d904e2d8672dee7dc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@pierre/highlights': + specifier: 'catalog:' + version: 0.2.0 '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6)) @@ -544,7 +550,7 @@ importers: version: 3.1.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-native-menu/menu': specifier: 2.0.0 - version: 2.0.0(patch_hash=a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + version: 2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/elements': specifier: 2.9.45 version: 2.9.45(d8845bac868f4d8a58c8a8f12ad0d4a5) @@ -554,24 +560,15 @@ importers: '@react-navigation/native-stack': specifier: 7.21.0 version: 7.21.0(patch_hash=990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42)(52d9b4ccdf06285c715244e02709d347) - '@shikijs/core': - specifier: 4.2.0 - version: 4.2.0 - '@shikijs/engine-javascript': - specifier: 4.2.0 - version: 4.2.0 - '@shikijs/langs': - specifier: 4.2.0 - version: 4.2.0 - '@shikijs/themes': - specifier: 4.2.0 - version: 4.2.0 '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/mobile-highlights-native': + specifier: file:./modules/t3-highlights + version: file:apps/mobile/modules/t3-highlights '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -744,8 +741,8 @@ importers: specifier: 0.25.1 version: 0.25.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-enriched-markdown: - specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz - version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: file:deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz + version: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-gesture-handler: specifier: 3.3.0 version: 3.3.0(patch_hash=abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -770,9 +767,6 @@ importers: react-native-screens: specifier: file:deps/react-native-screens-5.0.0-t3.43.tgz version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.43.tgz(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) - react-native-shiki-engine: - specifier: ^0.3.12 - version: 0.3.12(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-svg: specifier: 15.16.0 version: 15.16.0(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -788,9 +782,6 @@ importers: remark-parse: specifier: 'catalog:' version: 11.0.0(supports-color@8.1.1) - shiki: - specifier: 4.2.0 - version: 4.2.0 tailwind-merge: specifier: ^3.5.0 version: 3.6.0 @@ -992,8 +983,8 @@ importers: specifier: 16.14.1 version: 16.14.1 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -1037,8 +1028,8 @@ importers: specifier: 'catalog:' version: 1.8.0 '@pierre/diffs': - specifier: 1.5.2 - version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.6.1 + version: 1.6.1(patch_hash=4645364ddf113e48e8827f3b4966b3b1ff745317011e5a3d904e2d8672dee7dc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -1213,10 +1204,10 @@ importers: version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@tailwindcss/vite': specifier: 4.3.3 - version: 4.3.3(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)) + version: 4.3.3(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.161.0 - version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(supports-color@8.1.1) + version: 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(supports-color@8.1.1) '@types/compression': specifier: ^1.8.1 version: 1.8.1 @@ -1240,7 +1231,7 @@ importers: version: 0.3.0 '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.152.0) + version: 6.1.2(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.152.0) '@vitest/browser-playwright': specifier: 5.0.5 version: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) @@ -1257,11 +1248,11 @@ importers: specifier: 4.3.3 version: 4.3.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@1.3.0 - version: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@1.4.0 + version: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -1294,7 +1285,7 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.80 - version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(2676e7bf1eae706a0c10853e80d0a1b0) + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ed9f72c53f648b6275121476d8becac3) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.4.3) @@ -1321,11 +1312,42 @@ importers: specifier: 'catalog:' version: '@tsc-rs/typescript@0.8.0' vite: - specifier: npm:@voidzero-dev/vite-plus-core@1.3.0 - version: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + specifier: npm:@voidzero-dev/vite-plus-core@1.4.0 + version: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + + infra/task-cache: + dependencies: + alchemy: + specifier: 2.0.0-beta.80 + version: 2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(93312816496722c8e540690c17876476) + effect: + specifier: 4.0.5 + version: 4.0.5 + jose: + specifier: 'catalog:' + version: 6.2.2 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260601.1 + version: 4.20260604.1 + '@effect/platform-node': + specifier: 4.0.5 + version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.5 + version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + typescript: + specifier: 'catalog:' + version: '@tsc-rs/typescript@0.8.0' + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -1343,14 +1365,14 @@ importers: specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: '@pierre/diffs': - specifier: 1.5.2 - version: 1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.6.1 + version: 1.6.1(patch_hash=4645364ddf113e48e8827f3b4966b3b1ff745317011e5a3d904e2d8672dee7dc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -1377,8 +1399,8 @@ importers: specifier: 0.180.0 version: 0.180.0 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -1390,8 +1412,8 @@ importers: specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -1412,8 +1434,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -1434,8 +1456,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-acp: dependencies: @@ -1468,8 +1490,8 @@ importers: specifier: workspace:* version: link:../effect-acp vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-acp-registry: dependencies: @@ -1508,8 +1530,8 @@ importers: specifier: workspace:* version: link:../effect-acp vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-core: dependencies: @@ -1533,8 +1555,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-cursor: dependencies: @@ -1573,8 +1595,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-grok: dependencies: @@ -1610,8 +1632,8 @@ importers: specifier: workspace:* version: link:../effect-acp vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-muse: dependencies: @@ -1644,8 +1666,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-opencode: dependencies: @@ -1687,8 +1709,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-pi: dependencies: @@ -1718,8 +1740,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-testing: dependencies: @@ -1822,8 +1844,8 @@ importers: specifier: 'catalog:' version: 2.0.2 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-azure-devops: dependencies: @@ -1856,8 +1878,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-bitbucket: dependencies: @@ -1890,8 +1912,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-core: dependencies: @@ -1912,8 +1934,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-definitions: dependencies: @@ -1946,8 +1968,8 @@ importers: version: link:../source-control-gitlab devDependencies: vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-forgejo: dependencies: @@ -1980,8 +2002,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-gitcafe: dependencies: @@ -2011,8 +2033,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-github: dependencies: @@ -2048,8 +2070,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-gitlab: dependencies: @@ -2076,8 +2098,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-testing: dependencies: @@ -2123,8 +2145,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -2142,8 +2164,8 @@ importers: specifier: 24.12.4 version: 24.12.4 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -2194,8 +2216,8 @@ importers: specifier: npm:typescript@~6.0.3 version: typescript@6.0.3 vite-plus: - specifier: 1.3.0 - version: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -5040,8 +5062,8 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/runtime@0.154.0': - resolution: {integrity: sha512-cBQNGoh659BNgvA8OC/e4BNDtFkv03dJaYdkp2adgOQNtISNAPQ+nB4S8XvWtNk7Xl+0g+7aa+iRGR25Xycd1Q==} + '@oxc-project/runtime@0.155.0': + resolution: {integrity: sha512-VbW9raczGS5ZZ1FMunUBYHe4uV/R684qlQCoMdE3n5JT1PcqQsvmFpMYkF8AD+W04fBLGAq6Tr7YsTk0jjvDBA==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.127.0': @@ -5059,8 +5081,8 @@ packages: '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} - '@oxc-project/types@0.154.0': - resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -5287,124 +5309,124 @@ packages: cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.73.0': - resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} + '@oxfmt/binding-android-arm-eabi@0.74.0': + resolution: {integrity: sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.73.0': - resolution: {integrity: sha512-dBoypjC2/xeyKyIhN2waX0/XeoccH9k0lVVe+sGRc4X8HSJVRtqyZDoXdr7bGY9A75c51dX0z0dLDpGQKwCdCg==} + '@oxfmt/binding-android-arm64@0.74.0': + resolution: {integrity: sha512-FJW3sATVvrWbV64al8xofCPZj4500Z/1W0ad7Tg1rcCUyh0EMbuSrsf3jsZS7CEbwQ5fQ0mRhmkc129GFgofSQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.73.0': - resolution: {integrity: sha512-Tn7Y+qWIpCQK+qdYwBcn6YvqeBuPiuG3tcNlYg16a5QL2o3rSmrgBB1nCyYqo0uvc1LNrfQW9/CK2tzFuID/Fg==} + '@oxfmt/binding-darwin-arm64@0.74.0': + resolution: {integrity: sha512-TfmNKBD30dGyMu9GCLiaw2rt/z7fms9LS+5cKcNOyWst0QgAs8oZs6wPxMbVaaDVE0/PCdCosgWM1vbu6f5NUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.73.0': - resolution: {integrity: sha512-yVb0E0tg/+9Xc9YJCBq66qF1DC/aN5mhb5nnHz0k4ikwkMANtSul2VPDSwjYdXR6/DFimc+pPUgLBqkIa5Ctuw==} + '@oxfmt/binding-darwin-x64@0.74.0': + resolution: {integrity: sha512-5Usf8+DfC4Q3o/MD8FkN47GrI6y0KbhQ4KhZ2HGzMZcXjcW1AeldwbUn51aKih+1TVuJFF9qkNm6X1nzm6NYng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.73.0': - resolution: {integrity: sha512-IKR4lFnBkYNL9Uqdxa5GMTQLvnLj+3sDwTvh2atHCzah0xeo+1xR5bigRU8uyXlOsA4VpXUn0UAimq4E4RBGmQ==} + '@oxfmt/binding-freebsd-x64@0.74.0': + resolution: {integrity: sha512-eL+1O3gykw1XihbZh2MRAciQvU2988LByrnjhyo/dmNG74X4oZ7XzigRoWPRFLfJCp8hzRkp/LbEhwe7uuT2eA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': - resolution: {integrity: sha512-7qnZ4g0ZCO3aZevOOQpZIABYwwXJW1lLDo2elviGdPQ0i11255xKEMFsi97fC1/nauQFseCJfELUy/k2JoOP+g==} + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': + resolution: {integrity: sha512-Xt0QtO2hJ4stnul8IL4LJGVLyp0xnc6ccN9fZ30yuubv8FWSRYxWwd1Za62KsEqAfmPSazoS/cLSUUJpja6npA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.73.0': - resolution: {integrity: sha512-36eBico2j/WPB84gb4FYd/jjqGCKOwU4M+TWLBUIqlnzTozQueLy0joA5BwsSp2OZFVkOlpCwvA3ndPamdiyLw==} + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': + resolution: {integrity: sha512-/4wYQ0gZl4vUR0QoBTGaay73FmQ1GAjUa9z9101TR2xbGNrg4a+grazlR0yzEOe3/E+PJr5BGfj66MMzm/54vg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.73.0': - resolution: {integrity: sha512-VZ3cSgCAT18knKo52DcACmAuIoqG6qVEyeZRFbJd0xmYQ8GyYjuBc6IAh6MZHijT6q7p4/HPI+6ZWsCuhdV6iw==} + '@oxfmt/binding-linux-arm64-gnu@0.74.0': + resolution: {integrity: sha512-846bKzF+HJd+JkOFh3rxzmYvSq4Cm+GMT+p+mW7S13ZYo6GAAGv8bL8cAOi/XcdeMqxEJDCIMTVQLVBXHMCdig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.73.0': - resolution: {integrity: sha512-8YVzH59XSHcqwXNgc5qK851zCqwpmBgOifARXEPPe5/xTsIbHceYoPZAyqfpYV0CfKmzSaEDBgYHFza4rhx+Zw==} + '@oxfmt/binding-linux-arm64-musl@0.74.0': + resolution: {integrity: sha512-nI5FJwZ4Flo6CBdscpwxymZXxO3m3IErQJX55ZS6h+ObfGXu8FostL85L/RWPFaCLnIVE4/WfLw+2q8XvVEeUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.73.0': - resolution: {integrity: sha512-MqRJnmCys0rwOgpWyEidSA7v/bvOqfNDJhPkQoR9mPVYrvSmMaW2Xhdpl28cbTA+brOM1is62GjIvJplVlUVpw==} + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': + resolution: {integrity: sha512-BoQYKuqUdsKAFAkK6DLBvkdv4+t0hppSm79sB0wIcE2o/eOwNj+6Z3OlKY4q3Eta/tlssP65FaS9vHn8TeF7vQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.73.0': - resolution: {integrity: sha512-4t2F3SWJSK/O/kRRFnL0elJzn75fJGGu71gs1HtiWcS80SmJOpkHiwwLnTp9suGdns8VHEPxYS0GTcyVwHKWBA==} + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': + resolution: {integrity: sha512-FSroRrM12+jCdSQklF9aOxsKpI6wFs4ZCo/1cSYG80RWZS6z4LV+l9tXRMT8lDGu+yohIZBwi9M76FzGPFDE2A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.73.0': - resolution: {integrity: sha512-D8b4I8I1kLdJQFwdFXNV+cwh3xWSiNKofjdGUk0nDpc0AHOds8T+poGbP9RWQMvgsvMb9OMgp/aV7ahBR37TbQ==} + '@oxfmt/binding-linux-riscv64-musl@0.74.0': + resolution: {integrity: sha512-0VLQD0dhGBLY9Ik/w+otMvD81D0YXi4VDwql65macV+O9UzmSWmUr6CKf+dMtysDeC0PnG3y6kBkRO08BJx52g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.73.0': - resolution: {integrity: sha512-QrC1S5VL1+N5rvorB15jH7CPvjVHA7GyUI5oVEd8Bd9m5605RDBQYTxDQ920DuylRoZOh2V7/xXpfCtFUrojUQ==} + '@oxfmt/binding-linux-s390x-gnu@0.74.0': + resolution: {integrity: sha512-6X7BbZNz34+OHM6Oqjf6TRcUyRn6aRcB4VtIDZAF/uQRyeVhg+G2hv6MlMvmnRekVSyTJ/8rG/u8W2dRFW6glg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.73.0': - resolution: {integrity: sha512-dFcUKQPltCEPa+YlkF3zqufn6nJPZdG73LpnjBvZFFsU3laCMssDXSYB99mVaj43FqdEgjpz5TO6gbYXcczd9g==} + '@oxfmt/binding-linux-x64-gnu@0.74.0': + resolution: {integrity: sha512-4kYIQK53yJXrxwxSzx8H2bojlf6/KezLGYLRXzV9QCCkoiCsvBTCid9Bg83dUPTLPa3g9K0730f6MKDbck1+OA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.73.0': - resolution: {integrity: sha512-LmQe+nV8e+eUwfPNCxDF+owbsg88toBiR/8rZRgVOeKQP3MaGefXZRvBoc1UG37iWEQ/9JlCXG9Mc4nf4N3agw==} + '@oxfmt/binding-linux-x64-musl@0.74.0': + resolution: {integrity: sha512-MfWz4wDREjp/JQDmuxtKieix4FHGa1z5rBvB3betdcPwZvf5fqeQrlCm1mmoyuUVQOh9Pt3M61HwN94kJCxw7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.73.0': - resolution: {integrity: sha512-8weOr1OKknE0G2DbR6ejJUT9AOsoBU6cXlsetmbihHv1K+bK7qP61T6lJxY0k/aAO7GyOJiRGNlL+04r4eH8Xg==} + '@oxfmt/binding-openharmony-arm64@0.74.0': + resolution: {integrity: sha512-6rmAApkq5s1kbDjvNI6Nl5A+JqQkLORb2sR941pf5HWsrd01WGKyagK/B4vX9+kh1FfvRkNgEu59FHvVk4H8cQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.73.0': - resolution: {integrity: sha512-mZV+QxXYgb8E18gzAFItb937VIsE4dqWdStrD3tRRC52UubOYEuU+e7NaIqUsGdB5YsJqNeuck3Ue/af2r9wyA==} + '@oxfmt/binding-win32-arm64-msvc@0.74.0': + resolution: {integrity: sha512-+5SpRUnZNGeXTyumHnpHa791tQtt04qZs076WT8eTcGF2OJ631VLnf7S0kQSPuDG+PtJM0phadEB2WAr5n1w9g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.73.0': - resolution: {integrity: sha512-QJPUsbgUb97ZPPMkFUq6VQq0fLhC4Np19KL9aB/qKVwXnpKE6MIiILXqzhKnQ/UbnEDjKJ7Xv1d6zH4bYPCUdQ==} + '@oxfmt/binding-win32-ia32-msvc@0.74.0': + resolution: {integrity: sha512-1Fg+wYBO3Dsti1QX+/6EYHjeQzLC84tLOAqPu/r7ah3UJlUs9C/DB703Ay9Vcg+8VMzqZA2P5VGhzJraVUD4OQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.73.0': - resolution: {integrity: sha512-cGcze2fu+ykIaFtkEvZlkZ3HvnCLk+WISthtq99zWTWn0x13t5L9TxvGdLZMUMvtfYR+ICkiCwKnguVubdcw0w==} + '@oxfmt/binding-win32-x64-msvc@0.74.0': + resolution: {integrity: sha512-y9jXUgPBm2TZIWbQhMYRRYAQM57+r4fbykgc+j24ICIIZl+pcuoNhnq1CRGm3oFiHnEUnZO00qOtSPGQdJ2ziQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5439,124 +5461,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.88.0': - resolution: {integrity: sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==} + '@oxlint/binding-android-arm-eabi@1.89.0': + resolution: {integrity: sha512-Pflpba5F3gRAQ12sWglRxvWTHEdAusNDvmi4Y5N1T5SY1AGkM7AixOnET8qkrnLJ/F32qAlNokO6eCknT8tmnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.88.0': - resolution: {integrity: sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==} + '@oxlint/binding-android-arm64@1.89.0': + resolution: {integrity: sha512-dYmyD1x7LaPlb79vZWUcpTShk8dI9z1I+IubtqrBxWNJaDhQdPHwcsvg7q5StTU4xWyd9AwPfLaTYFZY8NZgtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.88.0': - resolution: {integrity: sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==} + '@oxlint/binding-darwin-arm64@1.89.0': + resolution: {integrity: sha512-Z/oxOPiIqiS7fItRdxaaOEC+6GD3OpGi0IxoR0dMefzQHolsKLzScw2DNcu/5gUPok3tv+G9mslvYB5bo3i0qg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.88.0': - resolution: {integrity: sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==} + '@oxlint/binding-darwin-x64@1.89.0': + resolution: {integrity: sha512-eAjTkHSjAxrdCPJxO6y5sPQL5pYLKb2kvxZz9UU7tXnOZk9I/HLtkvx9S7GHY883k+AmL/eC7mqFt+w7WCwk0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.88.0': - resolution: {integrity: sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==} + '@oxlint/binding-freebsd-x64@1.89.0': + resolution: {integrity: sha512-0lNCA9Tl1n1FKpqy2/9ckp0kIuSrtJ/l9bMRgaEhOtM7TsOHshFuwOcvW8oA9teXLMOJT61LjEKWiaHDFGxF/w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.88.0': - resolution: {integrity: sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==} + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': + resolution: {integrity: sha512-OTFEvi9W+JczaZD1Caj17SGCoW8H7TE+fvx0D7ATEWa1Lohn91UMxFQ6r145SiGy/yCxeIwptMys/RAM7gSVSQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.88.0': - resolution: {integrity: sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==} + '@oxlint/binding-linux-arm-musleabihf@1.89.0': + resolution: {integrity: sha512-fRxgNHvBLswj6MrhW2uBq5UDhPRQlCbHeq/Nf8yuG9zOxhDsXqX/mi2PX1axgsQxMm88el7HLiJAzglAnflYxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.88.0': - resolution: {integrity: sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==} + '@oxlint/binding-linux-arm64-gnu@1.89.0': + resolution: {integrity: sha512-v9ENzHvwrQoYSWTtviH1pOYc7R9UovDxVofnck50cNmPihRRTcrg5X0UlRbLOZ7IVtxPOlh2hb7kDO+FpE8SfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.88.0': - resolution: {integrity: sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==} + '@oxlint/binding-linux-arm64-musl@1.89.0': + resolution: {integrity: sha512-vfffe8mQ484AzVadlUOFpgL+JDwhvs9hrzxTgQP1FVfi4ZEUUYeffXlzC9dFV7Dgnu5s/KrNKvfmG6HxBd/ISQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.88.0': - resolution: {integrity: sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==} + '@oxlint/binding-linux-ppc64-gnu@1.89.0': + resolution: {integrity: sha512-Buonlp4hVS7RGNP5M7bPpGNQrenyzA2C64JcO/oz9C1c6dGOXJ7VEAnaiAMY7voou0JYyCJdvDVlFQc5DZX3DQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.88.0': - resolution: {integrity: sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==} + '@oxlint/binding-linux-riscv64-gnu@1.89.0': + resolution: {integrity: sha512-1eVwJJrJ3lYIPT+cm7BcBBZZXY6IQZP/oyonaruTor/MrVyWOpvnwt1oD5CLdsHg5aVBIxXodQSvt+1gK6ck5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.88.0': - resolution: {integrity: sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==} + '@oxlint/binding-linux-riscv64-musl@1.89.0': + resolution: {integrity: sha512-fRQQsKUGShdEL5Fl9o9uhj3grkZbeXKLv0icQ5tspUCNYSDy8wzzEcP1WC2bKdTQ0rSszbKlO3iiHORxV22DLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.88.0': - resolution: {integrity: sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==} + '@oxlint/binding-linux-s390x-gnu@1.89.0': + resolution: {integrity: sha512-dEtYjIy6+fHcjKklsmtmMkZRhwHr2UbzjmpvuPtQYSeN9awQ2hx5JLms5Wvri+8P/NZtD8v5hfgHALUql+/Ycg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.88.0': - resolution: {integrity: sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==} + '@oxlint/binding-linux-x64-gnu@1.89.0': + resolution: {integrity: sha512-hqjwIj5KOYM8Wjef/p7IruNi1iByLhSzbMBk62y22P/E8q7Jj0Pp+DeUTKTRYyZYCDtU1wEGlnWOnttHbyNIww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.88.0': - resolution: {integrity: sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==} + '@oxlint/binding-linux-x64-musl@1.89.0': + resolution: {integrity: sha512-iAQN3e6o9UgUCcdGwqRx/jYcKbG3HK7ur3rH6lTqVcQeNGZ85AeWIg6cgn/edwFQ0ofEZZItMwkzyCVTahnvEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.88.0': - resolution: {integrity: sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==} + '@oxlint/binding-openharmony-arm64@1.89.0': + resolution: {integrity: sha512-MNxjEByz3N/7Q4qFQuPWvc14TpN9LoD3pECEDsJTZbP/wyB7U+hduUXEl7QAbbgVezFwsb9sWtNUO6XVBndOhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.88.0': - resolution: {integrity: sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==} + '@oxlint/binding-win32-arm64-msvc@1.89.0': + resolution: {integrity: sha512-xxe+i9wfuFso/VnoL4mfj0UlzE+cfCFiUB06vkXCMyQdgVVmItF5FfAF4tATAktSU6uHruWR+FjqvAT9cHZZzg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.88.0': - resolution: {integrity: sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==} + '@oxlint/binding-win32-ia32-msvc@1.89.0': + resolution: {integrity: sha512-saFy07cO94E664glwgLmKLI40HgTL1PY86y4T6+ud4niX4H0Kdqjcmqmjps7mQNdBytV5guGbXDdyQtRfeLbLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.88.0': - resolution: {integrity: sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==} + '@oxlint/binding-win32-x64-msvc@1.89.0': + resolution: {integrity: sha512-hLFJ/o0f/dTJh/EW0vmcbREYSQO9tYgfiAAlsOkiLFiJuRRWvYtFELUXN5o2ijpSo/tlO1n+YlWN58tZwPG18A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -5565,8 +5587,8 @@ packages: resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@oxlint/plugins@1.88.0': - resolution: {integrity: sha512-G2xbfWTrbqYRhR/vfeKNMQf/sS3/ti0iKJAAkRxHuXWZH/hne/dyIb9QKk5LuR6cJLARxsL6FMoAYmfspd74MQ==} + '@oxlint/plugins@1.89.0': + resolution: {integrity: sha512-TJBBNl4KIxbxrSk7gsqlz6iM1kxidfyTizEzfWqXvijF2Ou8/Da58GvjemBVmCmaUamwRij+JwsVc0kpBFuqfg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@peculiar/asn1-schema@2.8.0': @@ -5583,8 +5605,8 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.5.2': - resolution: {integrity: sha512-QVxIWQEnNoko22k63maERzozd29+FtaUNpRrZiw603WMe5R1mFSED+jMtPkcVP3SDYS1DWg5K7sa7sJbr3xkNQ==} + '@pierre/diffs@1.6.1': + resolution: {integrity: sha512-XTfLW4bFETyosZfFNUoyRh1zPE4tmAijOoxdHx4X805tlaq/7/fuQXIiJSm21+jLkDo8vn5elQ+2O4YtMTKM0Q==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -5594,6 +5616,9 @@ packages: react-dom: optional: true + '@pierre/highlights@0.2.0': + resolution: {integrity: sha512-hTpcOU1EzDAP4bYtEhxmMu2jwMvtkUydRUBUpWMSHTs3dw0IaC3Ld5ansMy18L0GE3b6Vfl2OGkLK+pWWpnBmw==} + '@pierre/theme@1.1.0': resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} @@ -5622,8 +5647,8 @@ packages: shiki: optional: true - '@pierre/theming@1.0.1': - resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==} + '@pierre/theming@1.0.2': + resolution: {integrity: sha512-RmXLLqtx31UGcR8kZyO5hGAQ1EXz6loT1Cu0BFAGsbnBboIxp2BlomkLfWyvdT4WZXnBGyu25onL1gKpFQuSUw==} peerDependencies: '@pierre/theme': ^1.1.0 || ^2.0.0 '@shikijs/themes': ^3.0.0 || ^4.0.0 @@ -6074,18 +6099,10 @@ packages: resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==} engines: {node: '>=20'} - '@shikijs/transformers@4.2.0': - resolution: {integrity: sha512-pKrYVNUr1oPjJvs76gkPPirDySx3GKG9O88P2Y3AQ+7AjSFws9Y+Ry/Q/6Yg6QpyigzjdQ6H5JAMNAvLXZ63dw==} - engines: {node: '>=20'} - '@shikijs/types@4.2.0': resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==} engines: {node: '>=20'} - '@shikijs/types@4.3.0': - resolution: {integrity: sha512-oc8b9U2SYvofKZk8e/737nIX0qwf6eV2vHFATeObAu7r+mUVpLs8Re0BmVkIjAWAYgkmG/CzLNo7rzuBzRu/wQ==} - engines: {node: '>=20'} - '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -6155,6 +6172,9 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3tools/mobile-highlights-native@file:apps/mobile/modules/t3-highlights': + resolution: {directory: apps/mobile/modules/t3-highlights, type: directory} + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} @@ -6806,14 +6826,14 @@ packages: '@vitest/utils@5.0.5': resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} - '@voidzero-dev/vite-plus-core@1.3.0': - resolution: {integrity: sha512-Bk/LBwNrxoB8XbGUtGJE3r+U9P4puvdZ6UH36ekygE1J+on/ytwM/yBgQwBnvmX6/MVax+2xxj8wk0N/KkyNeA==} + '@voidzero-dev/vite-plus-core@1.4.0': + resolution: {integrity: sha512-LTGXCdZ58qJKfuCOmf0hnZOVA64eOgzDQOqYpI+71tPxk/0cGqPQhYDGQQZoEvNLAn1G2Lo4gKadsQdWxb99WQ==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 '@types/node': 24.12.4 '@typescript/native-preview': '*' - '@vitejs/devtools': ^0.7.1 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 '@volar/typescript': ~2.4.0 '@vue/language-core': ~3.2.0 || ~3.3.0 esbuild: ^0.27.0 || ^0.28.0 @@ -6875,54 +6895,54 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@1.3.0': - resolution: {integrity: sha512-BF4W+UwGxUkHPrH2pZLhafJfkJT20WZIbMzZgZLGQ44LPpZPX8Sb+qtblUnXUkkCzTqad4XtSPgiLKglvZkNQQ==} + '@voidzero-dev/vite-plus-darwin-arm64@1.4.0': + resolution: {integrity: sha512-5Ie3vnrFrF5GFU+0VhbLuyoSmnzd8m42BvbLjncMOmAb6D1sEq4g37dlxPiVcJEqITM0t4zFnLkNSF4YAlk0FQ==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@1.3.0': - resolution: {integrity: sha512-vLZTk0r+s3e7mycj4SGBbcpK+xyM8a86hXQZtQbJyW7xrGJzsavISy7/m4gGZpWN32Hl4Tmx9V/ffeaoXLkRdg==} + '@voidzero-dev/vite-plus-darwin-x64@1.4.0': + resolution: {integrity: sha512-vaR7F+ix/91EycJYXOQ24Nzw470U12x3L+YLbbtuRwHDMW67fCbhXzcW+otZS8q4eRdIzEcA5svv9P8kcsaRkA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@1.3.0': - resolution: {integrity: sha512-H43Dj9RkEyjOFXxTk3DLTa6hqmHhxUN9vbbqNdwQoc3/TPNhC1zsLZy3qgUFbYGLdS7M9jICCqFzj5GDSYrhIw==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.4.0': + resolution: {integrity: sha512-58/PZrp+f3Hy6kFVKqbrZVenjo5t012rTOgRSgMoJyOhJjXBHqvORh9lB9GIip9r9maoWN31R1a44obPX9MD7g==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@1.3.0': - resolution: {integrity: sha512-Se2GlvzUZOvFy+5hoQHoG7LFeiplKs+EJwaskayCSLwZAP+e6/LTK7EK+8OcdMpmdsLPtZoTJHkIjSzrQowJwQ==} + '@voidzero-dev/vite-plus-linux-arm64-musl@1.4.0': + resolution: {integrity: sha512-Ij08vPrxG1J23wQVm0LtLPrQevv4tos8TirP/jGTpQQmQAgYOpmIfMrx8BF+vOGMncOIo+kKacp4jPVmhctfhg==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@1.3.0': - resolution: {integrity: sha512-moxojk6zmog2SaLE4/cG75acE6X3cjR0/52Ba8OG2ct0r+dv55VSE0kellAuxS6o9VHy7TBhkIpwpFpQ34rnAA==} + '@voidzero-dev/vite-plus-linux-x64-gnu@1.4.0': + resolution: {integrity: sha512-RbM21Y5cSx5WesaFV/Swo22JRvimjO6NDItoL5wNI6NTa987ck7v19HBmp+V4nkOCFHbMdxwfhyALDw0y06xEA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@1.3.0': - resolution: {integrity: sha512-CykxH008Dy9u20Myki9/UIqq3Dbo1ZMqa098aS6AtWp3QKyEKzSLPB3jKDUklgmu83xiFe5BpPA+33oF0yNY7A==} + '@voidzero-dev/vite-plus-linux-x64-musl@1.4.0': + resolution: {integrity: sha512-Q+p+YJztEmqjJoumTmPaBQ/PEx3eFhiNTQTn5+85gkBUiRexkFQ8A86TMFxbKVgrJbiAOltkrJRmVRhRs5Q0sA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-win32-arm64-msvc@1.3.0': - resolution: {integrity: sha512-l61S9b8aeiDpi8wPRfz4HonqRkLpt/OVYs0sCskvlXYSW/L8qfLOtuWH/xpF8xDjMIfBefKejBVfU1UlRax5Tw==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.4.0': + resolution: {integrity: sha512-PLGVov8c4/QfveOM/AYjYDlgMCQ0SxCmJp392qBgxaSXP0nEg6yP4hn5UtnhxvluhHEzVkTf2r1Q3LNlZRI7kw==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@1.3.0': - resolution: {integrity: sha512-b6Ng8YezA6JA3adQBNA+8Mn26+OX4fw/tsPfnk//5Cx+1XFDZoAxWYXtGzc0yUQPEKnoGtfw/0OUvcibg61yYg==} + '@voidzero-dev/vite-plus-win32-x64-msvc@1.4.0': + resolution: {integrity: sha512-DYG7wDsMLLs2/+8qfeyLScdoMh+aPM4IZanoVPSNt8fMeeXR2GQiiZDKc0qZHKN9+YcdCGK1HLAdMAGSywKvsA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [win32] @@ -10208,14 +10228,14 @@ packages: resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} engines: {node: ^20.19.0 || >=22.12.0} - oxfmt@0.73.0: - resolution: {integrity: sha512-Ke4xbpuzqIBP4bkd48q815RhqWNJqOBzxVA9199M4qcF0y0LtpUv7UTg1dJ5ea6jw0RBUDZvX3qOUMNmmxfxbw==} + oxfmt@0.74.0: + resolution: {integrity: sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@astrojs/compiler-rs': ^0.4.0 || ^0.5.0 svelte: ^5.0.0 - vite-plus: 1.3.0 + vite-plus: 1.4.0 peerDependenciesMeta: '@astrojs/compiler-rs': optional: true @@ -10228,13 +10248,13 @@ packages: resolution: {integrity: sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==} hasBin: true - oxlint@1.88.0: - resolution: {integrity: sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==} + oxlint@1.89.0: + resolution: {integrity: sha512-a25hkfo61okGPvJz432fRIPoxlKtNnOVy9w4rzrmGgNm4iVTPwpSQlmdqNWnp6p9QyyPVfAZRgnf5XvSBZFk/A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: oxlint-tsgolint: '>=7.0.2003' - vite-plus: 1.3.0 + vite-plus: 1.4.0 peerDependenciesMeta: oxlint-tsgolint: optional: true @@ -10666,9 +10686,9 @@ packages: react: '*' react-native: '*' - react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz: - resolution: {integrity: sha512-dFBqwCTfgVXNU1wxwwZidHdpMxGCd+8jCPunbdO8fNG0uYVr3RQZmTwX/bC5JdgzFODZ8A7heqNBoukNpPiNtA==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz} - version: 1.2.0-t3.15 + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz: + resolution: {integrity: sha512-3eYvMd4EGUboGP4GoPLf5TF/hWqhPpbCB3AX/z1eQrEdiceizGNKszqGwoLV4M1KyGyWOf2UPNbviZoT21S+8g==, tarball: file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz} + version: 1.2.0-t3.16 peerDependencies: katex: '>=0.16.0' react: '*' @@ -10737,12 +10757,6 @@ packages: react: '*' react-native: '*' - react-native-shiki-engine@0.3.12: - resolution: {integrity: sha512-CE6CA3uHGZT5OmY909H+vTv5lrmILV7zuPOF2pXRYXWN2qYm5KG7XacEB/Pq1U2+8D0zJoeMveTny/FHEqnipg==} - peerDependencies: - react: '*' - react-native: '*' - react-native-svg@15.16.0: resolution: {integrity: sha512-uh8yktufYeRU3W9Omi8+SPyLF9BVN4JmLflSgc9oOcKkSKk/S5JUmmTjKZq/L8GHfQUtCvcu/W6X4xhEfBBAjQ==} peerDependencies: @@ -11748,8 +11762,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite-plus@1.3.0: - resolution: {integrity: sha512-wZGsDeYfPcrCegqgY68pJJHGTpEZdsZvIMH2/mWXkGkBD72UY3M7z24Sv3ZeOL/FrqbbtyqBOfkKO6BvoQ8wMw==} + vite-plus@1.4.0: + resolution: {integrity: sha512-bLyUKNFl0XjbHe20FpDM6OuknOb2E4ueoWKCW4n+wMO2+lQtA9tmAjW+Xs0Xe9rg2+tCgY3W13GOgDh2wjwjAQ==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} hasBin: true peerDependencies: @@ -12200,7 +12214,7 @@ snapshots: dependencies: json-schema: 0.4.0 - '@alchemy.run/cloudflare-runtime@2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5)': + '@alchemy.run/cloudflare-runtime@2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5)': dependencies: '@alchemy.run/node-utils': 2.0.0-beta.80 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260918.1) @@ -12217,7 +12231,7 @@ snapshots: optionalDependencies: '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) rolldown: 1.2.5 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - '@types/node' - proxy-agent @@ -13599,7 +13613,7 @@ snapshots: '@effect/vitest@4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': dependencies: effect: 4.0.5 - vite-plus: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) transitivePeerDependencies: - '@arethetypeswrong/core' - '@astrojs/compiler-rs' @@ -15129,7 +15143,7 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.148.0': optional: true - '@oxc-project/runtime@0.154.0': {} + '@oxc-project/runtime@0.155.0': {} '@oxc-project/types@0.127.0': optional: true @@ -15143,7 +15157,7 @@ snapshots: '@oxc-project/types@0.148.0': optional: true - '@oxc-project/types@0.154.0': {} + '@oxc-project/types@0.155.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -15263,61 +15277,61 @@ snapshots: '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': optional: true - '@oxfmt/binding-android-arm-eabi@0.73.0': + '@oxfmt/binding-android-arm-eabi@0.74.0': optional: true - '@oxfmt/binding-android-arm64@0.73.0': + '@oxfmt/binding-android-arm64@0.74.0': optional: true - '@oxfmt/binding-darwin-arm64@0.73.0': + '@oxfmt/binding-darwin-arm64@0.74.0': optional: true - '@oxfmt/binding-darwin-x64@0.73.0': + '@oxfmt/binding-darwin-x64@0.74.0': optional: true - '@oxfmt/binding-freebsd-x64@0.73.0': + '@oxfmt/binding-freebsd-x64@0.74.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.73.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.74.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.73.0': + '@oxfmt/binding-linux-arm-musleabihf@0.74.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.73.0': + '@oxfmt/binding-linux-arm64-gnu@0.74.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.73.0': + '@oxfmt/binding-linux-arm64-musl@0.74.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.73.0': + '@oxfmt/binding-linux-ppc64-gnu@0.74.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.73.0': + '@oxfmt/binding-linux-riscv64-gnu@0.74.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.73.0': + '@oxfmt/binding-linux-riscv64-musl@0.74.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.73.0': + '@oxfmt/binding-linux-s390x-gnu@0.74.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.73.0': + '@oxfmt/binding-linux-x64-gnu@0.74.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.73.0': + '@oxfmt/binding-linux-x64-musl@0.74.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.73.0': + '@oxfmt/binding-openharmony-arm64@0.74.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.73.0': + '@oxfmt/binding-win32-arm64-msvc@0.74.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.73.0': + '@oxfmt/binding-win32-ia32-msvc@0.74.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.73.0': + '@oxfmt/binding-win32-x64-msvc@0.74.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2004': @@ -15338,66 +15352,66 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2004': optional: true - '@oxlint/binding-android-arm-eabi@1.88.0': + '@oxlint/binding-android-arm-eabi@1.89.0': optional: true - '@oxlint/binding-android-arm64@1.88.0': + '@oxlint/binding-android-arm64@1.89.0': optional: true - '@oxlint/binding-darwin-arm64@1.88.0': + '@oxlint/binding-darwin-arm64@1.89.0': optional: true - '@oxlint/binding-darwin-x64@1.88.0': + '@oxlint/binding-darwin-x64@1.89.0': optional: true - '@oxlint/binding-freebsd-x64@1.88.0': + '@oxlint/binding-freebsd-x64@1.89.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.88.0': + '@oxlint/binding-linux-arm-gnueabihf@1.89.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.88.0': + '@oxlint/binding-linux-arm-musleabihf@1.89.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.88.0': + '@oxlint/binding-linux-arm64-gnu@1.89.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.88.0': + '@oxlint/binding-linux-arm64-musl@1.89.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.88.0': + '@oxlint/binding-linux-ppc64-gnu@1.89.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.88.0': + '@oxlint/binding-linux-riscv64-gnu@1.89.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.88.0': + '@oxlint/binding-linux-riscv64-musl@1.89.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.88.0': + '@oxlint/binding-linux-s390x-gnu@1.89.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.88.0': + '@oxlint/binding-linux-x64-gnu@1.89.0': optional: true - '@oxlint/binding-linux-x64-musl@1.88.0': + '@oxlint/binding-linux-x64-musl@1.89.0': optional: true - '@oxlint/binding-openharmony-arm64@1.88.0': + '@oxlint/binding-openharmony-arm64@1.89.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.88.0': + '@oxlint/binding-win32-arm64-msvc@1.89.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.88.0': + '@oxlint/binding-win32-ia32-msvc@1.89.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.88.0': + '@oxlint/binding-win32-x64-msvc@1.89.0': optional: true '@oxlint/plugins@1.68.0': {} - '@oxlint/plugins@1.88.0': {} + '@oxlint/plugins@1.89.0': {} '@peculiar/asn1-schema@2.8.0': dependencies: @@ -15421,11 +15435,11 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=2ec81ba1e8ce4681095a3473c2bf78e630417740c622720ef624febb467c25fc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.6.1(patch_hash=4645364ddf113e48e8827f3b4966b3b1ff745317011e5a3d904e2d8672dee7dc)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: + '@pierre/highlights': 0.2.0 '@pierre/theme': 2.0.0 - '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) - '@shikijs/transformers': 4.2.0 + '@pierre/theming': 1.0.2(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 @@ -15436,6 +15450,8 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' + '@pierre/highlights@0.2.0': {} + '@pierre/theme@1.1.0': {} '@pierre/theme@2.0.0': {} @@ -15448,7 +15464,7 @@ snapshots: react-dom: 19.3.0(react@19.3.0) shiki: 4.2.0 - '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': + '@pierre/theming@1.0.2(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)': optionalDependencies: '@pierre/theme': 2.0.0 '@shikijs/themes': 4.2.0 @@ -15507,7 +15523,7 @@ snapshots: react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': + '@react-native-menu/menu@2.0.0(patch_hash=910ec1bf22aa85a96fcf12299cd823bc30f702dd9b2d77b564d10b9ed709f7f6)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) @@ -15861,21 +15877,11 @@ snapshots: dependencies: '@shikijs/types': 4.2.0 - '@shikijs/transformers@4.2.0': - dependencies: - '@shikijs/core': 4.2.0 - '@shikijs/types': 4.2.0 - '@shikijs/types@4.2.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/types@4.3.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - '@shikijs/vscode-textmate@10.0.2': {} '@sinclair/typebox@0.27.10': {} @@ -15957,6 +15963,8 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3tools/mobile-highlights-native@file:apps/mobile/modules/t3-highlights': {} + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-source-text@file:apps/mobile/modules/t3-source-text(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0)': @@ -16035,12 +16043,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' '@tanstack/devtools-event-client@0.4.3': {} @@ -16103,7 +16111,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(supports-color@8.1.1)': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) @@ -16120,7 +16128,7 @@ snapshots: zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - supports-color @@ -16586,10 +16594,10 @@ snapshots: optionalDependencies: ajv: 6.15.0 - '@vitejs/plugin-react@6.1.2(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.152.0)': + '@vitejs/plugin-react@6.1.2(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.152.0)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: babel-plugin-react-compiler: 1.0.0 oxc-transform-react: 0.152.0 @@ -16600,7 +16608,7 @@ snapshots: '@vitest/mocker': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) playwright: 1.60.0 tinyrainbow: 3.2.0 - vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) transitivePeerDependencies: - '@arethetypeswrong/core' - '@types/node' @@ -16632,7 +16640,7 @@ snapshots: '@testing-library/dom': 10.4.2 '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) '@vitest/browser': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) - vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) transitivePeerDependencies: - '@arethetypeswrong/core' - '@types/node' @@ -16669,7 +16677,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@arethetypeswrong/core' @@ -16703,7 +16711,7 @@ snapshots: '@vitest/spy': 5.0.5 estree-walker: 3.0.3 magic-string: 1.4.3 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - '@arethetypeswrong/core' - '@types/node' @@ -16748,7 +16756,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) '@vitest/utils@5.0.5': dependencies: @@ -16756,23 +16764,23 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.2.0 - '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)': dependencies: - '@oxc-project/runtime': 0.154.0 - '@oxc-project/types': 0.154.0 + '@oxc-project/runtime': 0.155.0 + '@oxc-project/types': 0.155.0 lightningcss: 1.33.0 postcss: 8.5.30 yuku-parser: 0.19.0 optionalDependencies: '@types/node': 24.12.4 - '@voidzero-dev/vite-plus-darwin-arm64': 1.3.0 - '@voidzero-dev/vite-plus-darwin-x64': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-musl': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-musl': 1.3.0 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.3.0 - '@voidzero-dev/vite-plus-win32-x64-msvc': 1.3.0 + '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 '@volar/typescript': 2.4.28(@tsc-rs/typescript@0.8.0) esbuild: 0.28.2 fsevents: 2.3.3 @@ -16782,23 +16790,23 @@ snapshots: unrun: 0.2.39 yaml: 2.9.0 - '@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: - '@oxc-project/runtime': 0.154.0 - '@oxc-project/types': 0.154.0 + '@oxc-project/runtime': 0.155.0 + '@oxc-project/types': 0.155.0 lightningcss: 1.33.0 postcss: 8.5.30 yuku-parser: 0.19.0 optionalDependencies: '@types/node': 24.12.4 - '@voidzero-dev/vite-plus-darwin-arm64': 1.3.0 - '@voidzero-dev/vite-plus-darwin-x64': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-musl': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-musl': 1.3.0 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.3.0 - '@voidzero-dev/vite-plus-win32-x64-msvc': 1.3.0 + '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 '@volar/typescript': 2.4.28(typescript@6.0.3) esbuild: 0.28.2 fsevents: 2.3.3 @@ -16808,28 +16816,28 @@ snapshots: unrun: 0.2.39 yaml: 2.9.0 - '@voidzero-dev/vite-plus-darwin-arm64@1.3.0': + '@voidzero-dev/vite-plus-darwin-arm64@1.4.0': optional: true - '@voidzero-dev/vite-plus-darwin-x64@1.3.0': + '@voidzero-dev/vite-plus-darwin-x64@1.4.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@1.3.0': + '@voidzero-dev/vite-plus-linux-arm64-gnu@1.4.0': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@1.3.0': + '@voidzero-dev/vite-plus-linux-arm64-musl@1.4.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@1.3.0': + '@voidzero-dev/vite-plus-linux-x64-gnu@1.4.0': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@1.3.0': + '@voidzero-dev/vite-plus-linux-x64-musl@1.4.0': optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@1.3.0': + '@voidzero-dev/vite-plus-win32-arm64-msvc@1.4.0': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@1.3.0': + '@voidzero-dev/vite-plus-win32-x64-msvc@1.4.0': optional: true '@volar/kit@2.4.28(typescript@6.0.3)': @@ -17043,9 +17051,67 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(2676e7bf1eae706a0c10853e80d0a1b0): + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(93312816496722c8e540690c17876476): + dependencies: + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) + '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) + '@alchemy.run/node-utils': 2.0.0-beta.80 + '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@distilled.cloud/acme': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/aws': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/axiom': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/cloudflare': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/core': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/doppler': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/fly-io': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/gcp': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/hetzner': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/infisical': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/neon': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/planetscale': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/prisma': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/railway': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/stripe': 1.0.0-rc.13(effect@4.0.5) + '@distilled.cloud/zerossl': 1.0.0-rc.13(effect@4.0.5) + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@neon/functions': 0.11.0(hono@4.13.7) + '@octokit/rest': 22.0.1 + '@octokit/webhooks': 14.2.0 + '@types/aws-lambda': 8.10.161 + capnweb: 0.12.0 + effect: 4.0.5 + fflate: 0.8.3 + libsodium-wrappers: 0.8.4 + pathe: 2.0.3 + picomatch: 4.0.7 + rolldown: 1.2.5 + tinyglobby: 0.2.17 + yaml: 2.9.0 + optionalDependencies: + '@effect/platform-node': 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/vitest': 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + drizzle-kit: 1.0.0-rc.5-ab785fc + drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5) + mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) + pg: 8.23.0 + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - hono + - proxy-agent + - react-devtools-core + - typescript + - utf-8-validate + + alchemy@2.0.0-beta.80(patch_hash=bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd)(ed9f72c53f648b6275121476d8becac3): dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.80(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.5))(@effect/platform-node@4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6))(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.5)(rolldown@1.2.5) '@alchemy.run/floci': 2.0.0-beta.80(effect@4.0.5) '@alchemy.run/node-utils': 2.0.0-beta.80 '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -17089,7 +17155,7 @@ snapshots: drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.4.3) mongodb: 6.21.0(@aws-sdk/credential-providers@3.1062.0)(socks@2.8.9) pg: 8.23.0 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/node' @@ -17261,7 +17327,7 @@ snapshots: unifont: 0.7.5 unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0(supports-color@8.1.1)) verkit: 0.4.1 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vitefu: 1.1.3(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 @@ -18067,6 +18133,22 @@ snapshots: pg: 8.23.0 zod: 4.4.3 + drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0(effect@4.0.5))(@effect/sql-pg@4.0.5(effect@4.0.5))(@effect/sql-sqlite-bun@4.0.0-rc.112(effect@4.0.5))(@effect/sql-sqlite-do@4.0.5(effect@4.0.5))(@electric-sql/pglite@0.3.15)(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.5)(expo-sqlite@58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0))(pg@8.23.0)(zod@4.6.5): + optionalDependencies: + '@cloudflare/workers-types': 4.20260604.1 + '@effect/sql-d1': 4.0.0(effect@4.0.5) + '@effect/sql-pg': 4.0.5(effect@4.0.5) + '@effect/sql-sqlite-bun': 4.0.0-rc.112(effect@4.0.5) + '@effect/sql-sqlite-do': 4.0.5(effect@4.0.5) + '@electric-sql/pglite': 0.3.15 + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + bun-types: 1.3.14 + effect: 4.0.5 + expo-sqlite: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) + pg: 8.23.0 + zod: 4.6.5 + optional: true + dset@3.1.4: {} dunder-proto@1.0.1: @@ -20702,31 +20784,31 @@ snapshots: '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 - oxfmt@0.73.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.2.0 optionalDependencies: '@astrojs/compiler-rs': 0.5.1 - '@oxfmt/binding-android-arm-eabi': 0.73.0 - '@oxfmt/binding-android-arm64': 0.73.0 - '@oxfmt/binding-darwin-arm64': 0.73.0 - '@oxfmt/binding-darwin-x64': 0.73.0 - '@oxfmt/binding-freebsd-x64': 0.73.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.73.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.73.0 - '@oxfmt/binding-linux-arm64-gnu': 0.73.0 - '@oxfmt/binding-linux-arm64-musl': 0.73.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.73.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.73.0 - '@oxfmt/binding-linux-riscv64-musl': 0.73.0 - '@oxfmt/binding-linux-s390x-gnu': 0.73.0 - '@oxfmt/binding-linux-x64-gnu': 0.73.0 - '@oxfmt/binding-linux-x64-musl': 0.73.0 - '@oxfmt/binding-openharmony-arm64': 0.73.0 - '@oxfmt/binding-win32-arm64-msvc': 0.73.0 - '@oxfmt/binding-win32-ia32-msvc': 0.73.0 - '@oxfmt/binding-win32-x64-msvc': 0.73.0 - vite-plus: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@oxfmt/binding-android-arm-eabi': 0.74.0 + '@oxfmt/binding-android-arm64': 0.74.0 + '@oxfmt/binding-darwin-arm64': 0.74.0 + '@oxfmt/binding-darwin-x64': 0.74.0 + '@oxfmt/binding-freebsd-x64': 0.74.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.74.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.74.0 + '@oxfmt/binding-linux-arm64-gnu': 0.74.0 + '@oxfmt/binding-linux-arm64-musl': 0.74.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.74.0 + '@oxfmt/binding-linux-riscv64-musl': 0.74.0 + '@oxfmt/binding-linux-s390x-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-gnu': 0.74.0 + '@oxfmt/binding-linux-x64-musl': 0.74.0 + '@oxfmt/binding-openharmony-arm64': 0.74.0 + '@oxfmt/binding-win32-arm64-msvc': 0.74.0 + '@oxfmt/binding-win32-ia32-msvc': 0.74.0 + '@oxfmt/binding-win32-x64-msvc': 0.74.0 + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@7.0.2004: optionalDependencies: @@ -20737,29 +20819,29 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2004 '@oxlint-tsgolint/win32-x64': 7.0.2004 - oxlint@1.88.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.88.0 - '@oxlint/binding-android-arm64': 1.88.0 - '@oxlint/binding-darwin-arm64': 1.88.0 - '@oxlint/binding-darwin-x64': 1.88.0 - '@oxlint/binding-freebsd-x64': 1.88.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.88.0 - '@oxlint/binding-linux-arm-musleabihf': 1.88.0 - '@oxlint/binding-linux-arm64-gnu': 1.88.0 - '@oxlint/binding-linux-arm64-musl': 1.88.0 - '@oxlint/binding-linux-ppc64-gnu': 1.88.0 - '@oxlint/binding-linux-riscv64-gnu': 1.88.0 - '@oxlint/binding-linux-riscv64-musl': 1.88.0 - '@oxlint/binding-linux-s390x-gnu': 1.88.0 - '@oxlint/binding-linux-x64-gnu': 1.88.0 - '@oxlint/binding-linux-x64-musl': 1.88.0 - '@oxlint/binding-openharmony-arm64': 1.88.0 - '@oxlint/binding-win32-arm64-msvc': 1.88.0 - '@oxlint/binding-win32-ia32-msvc': 1.88.0 - '@oxlint/binding-win32-x64-msvc': 1.88.0 + '@oxlint/binding-android-arm-eabi': 1.89.0 + '@oxlint/binding-android-arm64': 1.89.0 + '@oxlint/binding-darwin-arm64': 1.89.0 + '@oxlint/binding-darwin-x64': 1.89.0 + '@oxlint/binding-freebsd-x64': 1.89.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.89.0 + '@oxlint/binding-linux-arm-musleabihf': 1.89.0 + '@oxlint/binding-linux-arm64-gnu': 1.89.0 + '@oxlint/binding-linux-arm64-musl': 1.89.0 + '@oxlint/binding-linux-ppc64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-gnu': 1.89.0 + '@oxlint/binding-linux-riscv64-musl': 1.89.0 + '@oxlint/binding-linux-s390x-gnu': 1.89.0 + '@oxlint/binding-linux-x64-gnu': 1.89.0 + '@oxlint/binding-linux-x64-musl': 1.89.0 + '@oxlint/binding-openharmony-arm64': 1.89.0 + '@oxlint/binding-win32-arm64-msvc': 1.89.0 + '@oxlint/binding-win32-ia32-msvc': 1.89.0 + '@oxlint/binding-win32-x64-msvc': 1.89.0 oxlint-tsgolint: 7.0.2004 - vite-plus: 1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -21208,7 +21290,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz(katex@0.16.47)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) @@ -21274,13 +21356,6 @@ snapshots: react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): - dependencies: - '@shikijs/types': 4.3.0 - '@shikijs/vscode-textmate': 10.0.2 - react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-svg@15.16.0(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: css-select: 5.2.2 @@ -22410,10 +22485,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: - '@oxc-project/types': 0.154.0 - '@oxlint/plugins': 1.88.0 + '@oxc-project/runtime': 0.155.0 + '@oxc-project/types': 0.155.0 + '@oxlint/plugins': 1.89.0 '@vitest/browser': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@vitest/browser-preview': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@vitest/mocker': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) @@ -22421,21 +22497,21 @@ snapshots: '@vitest/snapshot': 5.0.5 '@vitest/spy': 5.0.5 '@vitest/utils': 5.0.5 - oxfmt: 0.73.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.88.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.3.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2004 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vitest: 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) optionalDependencies: '@vitest/browser-playwright': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) - '@voidzero-dev/vite-plus-darwin-arm64': 1.3.0 - '@voidzero-dev/vite-plus-darwin-x64': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-arm64-musl': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-gnu': 1.3.0 - '@voidzero-dev/vite-plus-linux-x64-musl': 1.3.0 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.3.0 - '@voidzero-dev/vite-plus-win32-x64-msvc': 1.3.0 + '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 + '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 1.4.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 playwright: 1.60.0 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -22474,7 +22550,7 @@ snapshots: vitefu@1.1.3(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: - vite: '@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' transitivePeerDependencies: - '@arethetypeswrong/core' - '@types/node' @@ -22498,7 +22574,7 @@ snapshots: - vue-tsc - yaml - vitest@5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0): + vitest@5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0) @@ -22512,7 +22588,7 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: '@voidzero-dev/vite-plus-core@1.3.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@1.4.0(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(unrun@0.2.39)(yaml@2.9.0)' why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.12.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a0f8f5aa4c98..39b020f198872 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,7 +22,7 @@ allowBuilds: node-pty: true # Ships N-API prebuilds; its install script only rebuilds from source. objc-js: false - "react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.15-fee84114.tgz": true + "react-native-enriched-markdown@file:apps/mobile/deps/react-native-enriched-markdown-1.2.0-t3.16-8d2f84a8.tgz": true sharp: true "@tsc-rs/typescript": true utf-8-validate: false @@ -51,7 +51,8 @@ catalog: "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.5.2 + "@pierre/diffs": 1.6.1 + "@pierre/highlights": 0.2.0 "@tailwindcss/node": 4.3.3 "@tailwindcss/oxide": 4.3.3 "@tailwindcss/vite": 4.3.3 @@ -72,8 +73,8 @@ catalog: tldts: 7.4.2 typescript: npm:@tsc-rs/typescript@0.8.0 undici: 8.11.2 - vite: npm:@voidzero-dev/vite-plus-core@1.3.0 - vite-plus: 1.3.0 + vite: npm:@voidzero-dev/vite-plus-core@1.4.0 + vite-plus: 1.4.0 ws: 8.22.0 yaml: ^2.9.0 # Markdown pipeline: the server counts find matches with the same parser the @@ -219,6 +220,9 @@ minimumReleaseAgeExclude: - react-native-svg@15.16.0 - "@expo/schema-utils@58.0.2" - pele@1.8.0 + - "@pierre/diffs@1.6.1" + - "@pierre/highlights@0.2.0" + - "@pierre/theming@1.0.2" overrides: # Pin every patched package, including transitive dependencies, so lockfile-free @@ -234,7 +238,7 @@ overrides: expo-widgets: 58.0.20 "@ff-labs/fff-node": 0.11.0 "@legendapp/list": 3.3.5 - "@pierre/diffs": 1.5.2 + "@pierre/diffs": 1.6.1 "@react-native-ai/apple": 0.12.0 "@react-native-menu/menu": 2.0.0 "@react-navigation/native-stack": 7.21.0 @@ -365,7 +369,7 @@ patchedDependencies: "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch # Exports the dependency-light subpaths mobile and the web diff parser import, and keeps # measured wrapped-row heights above an edit (fileEditorVirtualization.test.ts). - "@pierre/diffs@1.5.2": patches/@pierre%2Fdiffs@1.5.2.patch + "@pierre/diffs@1.6.1": patches/@pierre%2Fdiffs@1.6.1.patch "@react-native-ai/apple@0.12.0": patches/@react-native-ai__apple@0.12.0.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.21.0": patches/@react-navigation%2Fnative-stack@7.21.0.patch diff --git a/scripts/build-cli-archive.ts b/scripts/build-cli-archive.ts index 1367c27c180d5..dfe1de081a214 100644 --- a/scripts/build-cli-archive.ts +++ b/scripts/build-cli-archive.ts @@ -1,8 +1,5 @@ #!/usr/bin/env node -/** - * The archive is the unit every runtime installer downloads, so nothing in it - * may require Node, npm, or a compiler on the machine that unpacks it. - */ +/** Runtime archives must run without host Node, npm, or compilers. */ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; @@ -92,8 +89,7 @@ export function cliArchiveStem(version: string, platform: BuildPlatform, arch: B } export function cliArchiveFileName(version: string, platform: BuildPlatform, arch: BuildArch) { - // gzip rather than xz: GNU tar needs an external xz binary for -J, which - // minimal hosts lack, while every tar (and Node's zlib) handles gzip alone. + // Minimal hosts may lack xz, but tar and Node zlib handle gzip without it. return `${cliArchiveStem(version, platform, arch)}.${platform === "win" ? "zip" : "tar.gz"}`; } @@ -124,10 +120,7 @@ const requireInput = Effect.fn("requireInput")(function* (inputPath: string, hin } }); -/** - * The tree is archived and unpacked on machines without pnpm, so the store - * layout cannot be relied on to survive the trip. - */ +/** Installed runtimes cannot rely on pnpm's store layout surviving archival. */ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (input: { readonly repoRoot: string; readonly stageDir: string; @@ -220,10 +213,7 @@ const stageRuntimeExternals = Effect.fn("stageRuntimeExternals")(function* (inpu ]) { yield* fs.remove(path.join(input.stageDir, entry), { recursive: true, force: true }); } - // A hoisted install still leaves nested `node_modules/.bin` shim directories - // inside packages that declare bins (msgpackr-extract's). They are symlinks - // nothing runs, and the npm registry refuses a tarball that contains any - // symlink, so strip every `.bin` directory below node_modules. + // Nested .bin shims are unused symlinks, which the npm registry rejects in tarballs. yield* removeNestedBinDirectories(fs, path, path.join(input.stageDir, "node_modules")); }); @@ -267,13 +257,7 @@ const MacSigningConfig = Config.all({ appleApiIssuer: Config.String("APPLE_API_ISSUER").pipe(Config.option), }); -/** - * Apple Silicon refuses to run unsigned Mach-O binaries at all, so the - * executable is always signed: ad hoc when no identity is configured, or with - * the Developer ID plus notarization when it is. The hardened runtime that - * notarization requires only loads signed libraries, so every native addon in - * the archive is signed with the same identity. - */ +/** Apple Silicon requires signed binaries; notarization's hardened runtime also requires every native addon signed. */ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (input: { readonly repoRoot: string; readonly contentDir: string; @@ -322,8 +306,7 @@ const signMacArchiveContents = Effect.fn("signMacArchiveContents")(function* (in ); return; } - // notarytool only accepts archives, and a bare executable cannot be stapled, - // so notarize a zip of the binary and rely on the online ticket lookup. + // notarytool requires an archive; bare executables rely on online tickets because they cannot be stapled. const notarizeZip = path.join(path.dirname(input.executablePath), ".notarize-t3.zip"); yield* runCommand( ChildProcess.make("ditto", ["-c", "-k", "--keepParent", input.executablePath, notarizeZip]), @@ -355,13 +338,7 @@ const WindowsSigningConfig = Config.all({ ), }); -/** - * Node's --build-sea injects the blob into a copy of node.exe by rebuilding its - * resource section but, unlike its Mach-O path, leaves node's original - * Authenticode data-directory entry in the PE header. The file grows, so the - * entry now points into the middle of the new section at bytes that are not a - * certificate table. - */ +/** Node's Windows --build-sea leaves a stale Authenticode offset pointing into rebuilt resources instead of certificates. */ const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(function* ( executablePath: string, ) { @@ -377,15 +354,13 @@ const stripStaleAuthenticodeEntry = Effect.fn("stripStaleAuthenticodeEntry")(fun } const optionalHeader = peOffset + 24; const magic = view.getUint16(optionalHeader, true); - // Data directories start at +112 (PE32+) or +96 (PE32); the certificate - // table is directory index 4, eight bytes (file offset, size). + // PE data directories start at +112 (PE32+) or +96 (PE32); index 4 holds the certificate offset and size. const securityEntry = optionalHeader + (magic === 0x20b ? 112 : 96) + 4 * 8; const offset = view.getUint32(securityEntry, true); const size = view.getUint32(securityEntry + 4, true); if (offset === 0 && size === 0) return; if (offset + size === bytes.byteLength) { - // A certificate table that still ends at EOF is intact; leave it for - // signtool to replace rather than second-guessing it here. + // Let signtool replace intact certificate tables ending at EOF. return; } view.setUint32(securityEntry, 0, true); @@ -444,16 +419,14 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); const executableName = input.platform === "win" ? "t3.exe" : "t3"; - // tsdown suffixes cross-built executables with their target (t3-darwin-x64); - // a host build is plain t3. Prefer the exact target when both exist. + // tsdown suffixes cross-built executables; prefer the exact target over the plain host build. const targetKey = `${input.platform === "mac" ? "darwin" : input.platform}-${input.arch}`; const targetExecutable = path.join( serverDir, "dist-exe", `t3-${targetKey}${input.platform === "win" ? ".exe" : ""}`, ); - // The unsuffixed host build is only a valid stand-in when it was built for - // this platform and architecture; otherwise a missing target must fail. + // The unsuffixed executable is valid only for the host platform and architecture. const hostPlatform = yield* HostProcess.Platform; const hostKey = `${hostPlatform === "win32" ? "win" : hostPlatform}-${yield* HostProcess.Architecture}`; const builtExecutable = (yield* fs.exists(targetExecutable)) @@ -525,11 +498,7 @@ const buildCliArchive = Effect.fn("buildCliArchive")(function* (input: { "tar (zip)", ); } else { - // On Linux, pnpm hard-links identical files out of its store and node-gyp - // hard-links build outputs, and GNU tar records those as link entries. - // The npm registry rejects a tarball containing any, and the npm platform - // packages are re-packed from this archive's contents, so store every - // file as a file. macOS's bsdtar has no such flag; pnpm clones there. + // npm rejects Linux hardlink entries; macOS pnpm clones and bsdtar lacks this flag. yield* runCommand( ChildProcess.make("tar", [ ...(input.platform === "linux" ? ["--hard-dereference"] : []), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 70ce48f0b84a8..d9afef3023244 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -82,9 +82,7 @@ const StageWorkspaceConfig = Schema.Struct({ cpu: Schema.Array(Schema.String), libc: Schema.optional(Schema.Array(Schema.String)), }), - // pnpm 11 only reads these from pnpm-workspace.yaml (not package.json#pnpm). - // Without allowBuilds the staged `vp install --prod` fails with - // ERR_PNPM_IGNORED_BUILDS for packages that have lifecycle scripts. + // pnpm 11 reads build approvals only from pnpm-workspace.yaml; omission fails staged lifecycle installs. allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), @@ -103,8 +101,7 @@ const StageWorkspaceConfig = Schema.Struct({ }); type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; -// electron-webauthn declares TypeScript as a peer only for its typings. pnpm -// auto-installs missing peers, which would ship a compiler inside the app. +// TypeScript is a typings-only peer; pnpm auto-installation would ship a compiler. const STAGE_PACKAGE_EXTENSIONS = { "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, @@ -591,11 +588,7 @@ const desktopBuildInputArtifactNames = { "bundled-server-client": "bundled server client", } satisfies Record; -/** - * Imported by every server module, so it is inlined in any correctly bundled - * build. Its absence means the bundle went back to externalizing its - * dependencies, which the sidecar's selected runtime closure does not cover. - */ +/** Effect is always inlined; absence signals externalized dependencies beyond the sidecar runtime closure. */ const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); @@ -982,14 +975,11 @@ interface StagePackageJson { export const STAGE_INSTALL_ARGS = ["install", "--prod"] as const; export const DESKTOP_ELECTRON_LANGUAGES = ["en-US"] as const; export const DESKTOP_FILE_EXCLUSIONS = [ - // Cursor finds platform assets by walking up from argv[1]. Keep them outside - // asar so spawning helpers and loading native addons both use real paths. + // Cursor walks from argv[1] to platform assets; helpers and native addons need real paths outside ASAR. "!**/node_modules/@cursor/sdk-*/**/*", "!apps/desktop/prod-resources/cursor-sdk", "!apps/desktop/prod-resources/cursor-sdk/**/*", - // T3 Code always passes the user's installed Claude executable to the SDK, - // so the SDK's optional platform packages (each a ~200MB bundled executable) - // are dead weight. The trailing dash keeps the SDK's own JS package. + // The installed Claude CLI is supplied explicitly; exclude optional bundled executables, retaining SDK JavaScript. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", "!**/*.map", "!**/*.d.cts", @@ -997,9 +987,6 @@ export const DESKTOP_FILE_EXCLUSIONS = [ "!apps/desktop/resources/browser-secret/**/*", "!apps/desktop/prod-resources/browser-secret", "!apps/desktop/prod-resources/browser-secret/**/*", - // Windows stages the server sidecar below prod-resources so electron-builder - // can copy it using project-relative extraResources matchers. Keep those - // staging inputs out of app.asar; they are emitted once at resources/. "!apps/desktop/prod-resources/windows-server", "!apps/desktop/prod-resources/windows-server/**/*", "!apps/desktop/prod-resources/wsl-runtime.tar.gz", @@ -1012,15 +999,13 @@ export const MAC_FILE_EXCLUSIONS = [ "!**/node_modules/node-pty/prebuilds/win32-*/**/*", "!**/node_modules/node-pty/third_party/conpty/**/*", ] as const; -// Linux builds node-pty from source, so every prebuild in the package is for -// another platform (58 MB of it Windows debug symbols). +// Linux builds node-pty from source; its prebuilds target other platforms. export const LINUX_FILE_EXCLUSIONS = [ ...MAC_FILE_EXCLUSIONS, "!**/node_modules/node-pty/prebuilds/darwin-*/**/*", ] as const; -// node-pty publishes both Darwin prebuilds in one package. Single-architecture -// apps only need the native target; universal apps need both. +// node-pty bundles both Darwin architectures; only universal apps need both. export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { if (arch === undefined || arch === "universal") { return [...MAC_FILE_EXCLUSIONS]; @@ -1029,14 +1014,9 @@ export function resolveMacFileExclusions(arch?: typeof BuildArch.Type) { const unusedArch = arch === "arm64" ? "x64" : "arm64"; return [...MAC_FILE_EXCLUSIONS, `!**/node_modules/node-pty/prebuilds/darwin-${unusedArch}/**/*`]; } -// Windows ships the server tree (bundle + node_modules) as a separate -// resources/server.asar sidecar instead of loose files: the NSIS installer then -// extracts a handful of large archives instead of thousands of small files, -// which dominates install (and update) time. +// Large ASAR sidecars avoid NSIS extracting thousands of loose files during installs and updates. export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; -// dlopen/spawn need real files, so native modules, shared libraries, and -// helper executables live in each archive's .unpacked sibling (the standard -// asar redirect convention). Everything else stays packed. +// Native modules and executables need real paths, so place them in ASAR's .unpacked sibling. export const WINDOWS_NATIVE_ASAR_UNPACK_GLOB = "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; export const WINDOWS_SERVER_ASAR_UNPACK_GLOBS = [ @@ -1046,10 +1026,7 @@ export const WINDOWS_SERVER_ASAR_UNPACK_GLOBS = [ "**/*.so.*", "**/*.dylib", ] as const; -// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK -// platform packages are dead weight (see above), and node_modules/.bin shims -// are never spawned at runtime (and are symlinks on POSIX build hosts, which -// the asar extraction path deliberately does not support). +// Keep sidecar exclusions aligned with the app; .bin symlinks are unused and unsupported by ASAR extraction. export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ "**/node_modules/@cursor/sdk-*", "**/node_modules/@cursor/sdk-*/**", @@ -1319,11 +1296,7 @@ function escapeXml(value: string): string { .replaceAll("'", "'"); } -/** - * Passkey entitlements for the in-app browser. Each is granted only when the - * provisioning profile authorizes it: macOS refuses to launch an app that - * claims a restricted entitlement its embedded profile does not carry. - */ +/** macOS refuses restricted entitlements absent from the provisioning profile. */ export interface MacWebAuthnEntitlements { readonly touchIdKeychainAccessGroup: string | undefined; readonly browserPasskeys: boolean; @@ -1445,8 +1418,7 @@ export function resolveFffNativeDependencies( ); } -// macOS and Linux run both processes from one app.asar, so the stage installs -// the union of what each bundle leaves external and nothing else. +// macOS and Linux share one app.asar; stage the union of both bundles' external dependencies. export function resolveMergedStageDependencies(input: { readonly platform: "mac" | "linux"; readonly serverDependencies: Record; @@ -1532,13 +1504,7 @@ export function resolveKeyringNativeArtifacts( })); } -/** - * Same nesting problem as the Clerk passkey binaries: pnpm keeps the platform - * package under `@napi-rs/keyring`, electron-builder only retains collected - * top-level dependencies, and the generated loader checks for a sibling - * `keyring..node` before falling back to the package. Staging the - * binary beside `index.js` lets that first branch win. - */ +/** electron-builder omits pnpm-nested platform packages; stage keyring's binary beside its loader, which checks there first. */ const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(function* ( stageAppDir: string, platform: typeof BuildPlatform.Type, @@ -1569,9 +1535,7 @@ const stageKeyringNativeBinaries = Effect.fn("stageKeyringNativeBinaries")(funct } }); -// pnpm nests the architecture package under @clerk/electron-passkeys, while electron-builder only -// retains collected top-level dependencies. The SDK loader checks beside index.js first, so stage -// the binary there and let electron-builder's native-addon handling unpack it from the ASAR. +// Stage pnpm-nested Clerk binaries beside index.js so the loader finds them and electron-builder unpacks them. const stageClerkPasskeyNativeBinaries = Effect.fn("stageClerkPasskeyNativeBinaries")(function* ( stageAppDir: string, platform: typeof BuildPlatform.Type, @@ -1612,9 +1576,7 @@ export function createStageWorkspaceConfig(input: { const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; - // Linux AppImages execute a Linux/glibc Node process that loads - // Linux-native optional deps at runtime. Keep libc explicit so pnpm - // includes those optional packages in the staged production install. + // Linux AppImages require glibc native optional dependencies; tell pnpm explicitly. const supportedArchitectures = platform === "linux" ? { @@ -1696,8 +1658,7 @@ function invalidMockUpdateServerPortReason( if (!Number.isFinite(parsed)) return "not-numeric"; if (!Number.isInteger(parsed)) return "not-integer"; if (parsed < 1 || parsed > 65535) return "out-of-range"; - // This mapper is only called after schema decoding failed. An otherwise - // valid integer therefore used a representation the decoder did not accept. + // Decoding already failed; valid integers here used unsupported representations. return "not-numeric"; } @@ -2096,10 +2057,7 @@ function trimTrailingSeparators(value: string): string { return value.slice(0, end); } -/** - * The share is the highest real directory on a UNC path: `\\server` on its own - * is not one, so the ancestor walk must stop there. - */ +/** Stop UNC ancestor walks at the share; a bare server is not a directory root. */ function uncShareRootLength(value: string): number { const isUnc = value.startsWith("\\\\") || value.startsWith("//"); if (!isUnc) return 0; @@ -2115,9 +2073,7 @@ export function ancestorNodeModulesPaths( startDir: string, separator: string, ): ReadonlyArray { - // Walks with lastIndexOf rather than splitting into segments so UNC roots - // (\\server\share) and drive roots keep their prefix instead of being - // rebuilt into a relative path that silently resolves against the build cwd. + // Splitting/rebuilding can lose UNC or drive roots; preserve their prefixes with lastIndexOf. const paths: string[] = []; let current = trimTrailingSeparators(startDir); const uncRootLength = uncShareRootLength(current); @@ -2193,10 +2149,7 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Effect's Node implementation delegates directory copies to fs.cp, whose - // default rewrites links into absolute source-tree references. Recreate every - // in-tree directory link as a junction rooted in the isolated copy so the - // probe cannot resolve through staging and Windows needs no symlink privilege. + // fs.cp rewrites links into the source tree. Recreate local junctions so probes remain isolated without symlink privileges. yield* fs.copy(source, destination); const restoreRelativeSymlinks = Effect.fn("build-desktop-artifact.restoreRelativeSymlinks")( @@ -2267,9 +2220,7 @@ export const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBund }); yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp); - // Guard the guard: if anything above the probe provides a node_modules, a - // missing dependency would resolve there and the check would pass while the - // packaged tree is broken. + // Ancestor node_modules could satisfy missing packaged dependencies and hide a broken stage. for (const candidate of ancestorNodeModulesPaths(probeApp, path.sep)) { if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { return yield* new BundleNotSelfContainedError({ @@ -2290,10 +2241,7 @@ export const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBund yield* runCommand( ChildProcess.make( process.execPath, - // --no-global-search-paths because clearing NODE_PATH is not enough: - // CommonJS resolution still falls back to $HOME/.node_modules, - // $HOME/.node_libraries and the install prefix, so a globally installed - // copy of a missing dependency would quietly satisfy this check. + // Clearing NODE_PATH leaves CommonJS global fallbacks; disable them so host packages cannot satisfy missing dependencies. ["--no-global-search-paths", entryPoint, "--version"], { cwd: probeApp, @@ -2338,8 +2286,7 @@ export const stageLinuxCaptureHelper = Effect.fn("stageLinuxCaptureHelper")(func const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const [rustTarget] = resolveResourceMonitorRustTargets("linux", input.arch); - // Release CI restores these binaries from a cache keyed on the crate sources and - // skips the Rust toolchain on a hit, so the build must be skippable too. + // CI skips Rust on helper-cache hits; allow reuse without rebuilding. const reuseHelpers = yield* Config.Boolean("T3CODE_DESKTOP_REUSE_LINUX_CAPTURE_HELPERS").pipe( Config.withDefault(false), ); @@ -2482,10 +2429,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp readonly verbose: boolean; }) { if (input.platform !== "linux") return; - // The helper links against the host's libsecret, so it can only be built on - // Linux; the build script is a no-op elsewhere. A Linux artifact from another - // host would ship without it and every v11 cookie import would report the - // keyring as unavailable, so refuse rather than package that silently. + // The helper links host libsecret and only builds on Linux; reject cross-host packaging that would silently omit it. const hostPlatform = yield* HostProcess.Platform; if (hostPlatform !== "linux") { return yield* new LinuxBrowserSecretHostError({ hostPlatform }); @@ -2795,9 +2739,7 @@ export function resolveMockUpdateServerUrl(mockUpdateServerPort: number | undefi return `http://localhost:${mockUpdateServerPort ?? 3000}`; } -// Electron Builder detects pnpm from npm_config_user_agent, whose value uses -// user-agent syntax (pnpm/11.10.0) rather than packageManager syntax -// (pnpm@11.10.0). +// electron-builder reads user-agent syntax (pnpm/version), not packageManager syntax (pnpm@version). export function resolvePackageManagerUserAgent(packageManager: string): string { const trimmed = packageManager.trim(); const versionSeparator = trimmed.lastIndexOf("@"); @@ -2846,9 +2788,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( directories: { buildResources: "apps/desktop/resources", }, - // Smart unpack extracts entire native packages, including JavaScript and - // metadata. Windows keeps those files archived so native dependencies do - // not inflate the loose-file count and slow NSIS installation. + // Disable smart unpack so native packages' JS/metadata stay archived and avoid slowing NSIS with loose files. ...(platform === "win" ? { asar: { smartUnpack: false }, asarUnpack: [WINDOWS_NATIVE_ASAR_UNPACK_GLOB] } : {}), @@ -2861,14 +2801,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], }; const updateChannel = resolveDesktopUpdateChannel(version); - // Local escape hatch: T3CODE_DESKTOP_IDENTITY= signs with - // any available cert (for example a self-signed one) so macOS updates pass - // Squirrel's seal/requirement validation without the full --signed Apple - // stack. Ignored when --signed is set. + // Local signing can use an available identity without the full --signed Apple stack. const macSignIdentity = yield* Config.String("T3CODE_DESKTOP_IDENTITY").pipe(Config.option); - // A missing `publish` makes electron-builder infer a GitHub feed from - // GH_TOKEN/GITHUB_TOKEN. The staged app has no repository to resolve it - // against, so writing update manifests then crashes on a null config. + // Missing publish config infers a GitHub feed from tokens; staging has no repository, so manifest generation would crash. buildConfig.publish = null; if (!isDesktopPreviewVersion(version)) { const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -2941,15 +2876,12 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "mac" && target === "dmg") { buildConfig.dmg = { - // Give the themed installer its own Finder volume name. Finder caches - // DMG window backgrounds by volume name, so reusing a generic name can - // make a newly built background look unchanged during testing. + // Finder caches DMG backgrounds by volume name; give each installer its own. title: `${resolveDesktopProductName(version)} ${version} Installer`, background: `dmg/dmg-background-${updateChannel}.png`, window: { width: 640, - // The DMG backend derives bounds from the image, including Finder's - // 32px title bar. Keep the last 32px of the artwork free of content. + // DMG bounds include Finder's 32px title bar; keep that artwork strip clear. height: 432, }, contents: [ @@ -2962,9 +2894,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( } if (platform === "linux") { - // electron-builder 26 defaults to its legacy AppImage runtime, which - // dynamically loads the system libfuse2 library. Pin the static runtime so - // the AppImage also launches on distributions that only provide FUSE 3. + // Pin the static AppImage runtime so hosts with only FUSE 3 do not need libfuse2. buildConfig.toolsets = { appimage: "1.0.3" }; const path = yield* Path.Path; const repoRoot = yield* RepoRoot; @@ -2993,8 +2923,7 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( `${path.join(repoRoot, "apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml")}=/usr/share/metainfo/com.t3tools.t3code.metainfo.xml`, `${path.join(repoRoot, "LICENSE")}=/usr/share/doc/t3code/copyright`, ], - // Debian 13 and Ubuntu 24.04 renamed some for 64-bit time; the old name - // is the fallback for older releases. + // Debian 13 and Ubuntu 24.04 use 64-bit-time names; retain old-name fallbacks. depends: [ "libasound2t64 | libasound2", "libatspi2.0-0t64 | libatspi2.0-0", @@ -3020,13 +2949,9 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const winConfig: Record = { target: [target], icon: "icon.ico", - // Smart App Control validates unpacked native addons independently from - // the signed desktop executable. Extend release signing to every native - // Windows library that Electron or the server sidecar loads at runtime. + // Smart App Control validates unpacked native libraries independently; sign them too. signExts: [".node", ".dll"], - // Resource editing applies the product metadata and icon independently - // of code signing. Disabling it for local unsigned builds leaves the - // packaged executable with Electron's stock icon. + // Resource editing sets product icons even when code signing is disabled. signAndEditExecutable: true, }; if (signed && (yield* HostProcess.Platform) === "win32") { @@ -3067,9 +2992,7 @@ const assertPlatformBuildResources = Effect.fn("assertPlatformBuildResources")(f } }); -// The desktop app verifies the digest inside the distro before extracting, and -// the digest also names the extracted runtime's cache directory, so it must be -// computed from the exact bytes that ship. +// The digest verifies extraction and names the cache; hash the exact archive bytes that ship. export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(function* (input: { readonly sourceArchivePath: string; readonly archivePath: string; @@ -3096,9 +3019,7 @@ export const stageWslRuntimeArchive = Effect.fn("stageWslRuntimeArchive")(functi ); }); -// Mirrors cliArchiveStem in scripts/build-cli-archive.ts (which imports from -// this module, so it cannot be imported here). WSL runs the same CPU arch as -// the Windows host. +// Match cliArchiveStem without its circular import. WSL uses the Windows host's architecture. export const wslRuntimeArchiveStem = (version: string, arch: typeof BuildArch.Type): string => `t3-${version}-linux-${arch}`; @@ -3115,10 +3036,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Native addons are unpacked only when they are Windows PE ("MZ") binaries. - // Signed builds Authenticode-sign every unpacked .node file, which fails on - // the darwin and linux prebuilds some packages (node-pty) ship alongside - // their win32 ones. The Windows primary never loads those, so they stay packed. + // Unpack/sign only Windows PE addons; packages also carry foreign prebuilds that Authenticode cannot sign. const unpackGlobs: string[] = [...WINDOWS_SERVER_ASAR_UNPACK_GLOBS]; for (const entry of yield* fs.readDirectory(input.sourceDir, { recursive: true })) { if (!entry.endsWith(".node")) continue; @@ -3141,8 +3059,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, unpack: `{${unpackGlobs.join(",")}}`, - // glob 13 (via @electron/asar 4) matches `ignore` relative to `cwd`, - // not against the absolute paths it crawls, so anchor it at the source. + // glob 13 (@electron/asar 4) resolves ignores against cwd; anchor them at the source. globOptions: { cwd: input.sourceDir, ignore: resolveWindowsServerAsarIgnoreGlobs(input.arch), @@ -3208,8 +3125,7 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( patchedDependencies: sidecarPatchedDependencies, overrides: input.overrides, }), - // The tree gets packed into server.asar, which cannot carry pnpm's - // symlink/junction layout, so install a physical, hoisted node_modules. + // server.asar cannot carry pnpm symlinks or junctions; install physical hoisted dependencies. nodeLinker: "hoisted" as const, }; const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); @@ -3442,9 +3358,7 @@ export const validateWindowsPackagedPayload = Effect.fn( const unpackedFiles = yield* Effect.try({ try: () => { - // @electron/asar resolves entry names using the host path separator. - // POSIX separators work on Linux/macOS but fail on Windows even when the - // entry is present in the archive. + // @electron/asar requires host separators even for archive entry names. statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs")); return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort(); }, @@ -3558,8 +3472,7 @@ export const validateWindowsPackagedPayload = Effect.fn( ); } const members = parseWslRuntimeArchiveMembers(listing.stdout); - // A release archive unpacks to one directory named after its stem; the - // desktop app's WSL install script relies on that layout to find `t3`. + // The WSL installer finds t3 under the archive's single stem-named directory. const stem = wslRuntimeArchiveStem(input.appVersion, input.targetArch); const topLevel = new Set(members.map((member) => member.split("/")[0])); if (topLevel.size !== 1 || !topLevel.has(stem)) { @@ -3729,9 +3642,7 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f } } - // An inlined native loader resolves its prebuilds relative to the bundle and - // quietly falls back to a slower pure-JS path, so this fails the build rather - // than shipping a silent regression. + // Inlined native loaders resolve relative to the bundle and can silently fall back to slower JS; reject them. { const chunkNames = (yield* fs.readDirectory(distDirs.serverDist)).filter((entry) => entry.endsWith(".mjs"), @@ -3751,8 +3662,7 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f packages: [...inlined].sort(), }); } - // No regions at all means the scan went blind (marker format changed), not - // that the bundle is clean. + // Zero regions means the marker scan failed, not that the bundle is clean. if (totalRegions === 0) { return yield* new InlinedExternalPackageError({ packages: [""], @@ -4016,9 +3926,7 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f }); } - // electron-builder treats several set-but-empty variables (e.g. CSC_LINK="") - // as enabled, so copy the host env and scrub empty values instead of relying - // on `extendEnv` merging. + // electron-builder treats empty signing variables as enabled; scrub them before spawning. const buildEnv: NodeJS.ProcessEnv = { ...process.env, }; @@ -4029,8 +3937,7 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f } } if (options.platform === "linux") { - // fpm compresses the .deb with the system xz through tar. Threaded mode - // takes seconds on a many-core runner instead of about two minutes. + // fpm uses system xz through tar; threading cuts compression time on many-core runners. buildEnv.XZ_DEFAULTS = "-T0"; } if (options.platform === "win") { diff --git a/scripts/build-npm-launcher.test.ts b/scripts/build-npm-launcher.test.ts index d1b620e1f2911..8dd98225cd073 100644 --- a/scripts/build-npm-launcher.test.ts +++ b/scripts/build-npm-launcher.test.ts @@ -45,7 +45,6 @@ const run = Effect.fn("test.run")(function* ( return { stdout, stderr, exitCode }; }, Effect.scoped); -/** A release archive for this host with a stub `t3` that echoes its args and launcher. */ const serveFakeRelease = Effect.fn("test.serveFakeRelease")(function* (root: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -117,11 +116,9 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { assert.equal(manifest.name, "t3"); assert.equal(manifest.version, VERSION); assert.deepStrictEqual(manifest.bin, { t3: "./bin/t3.mjs" }); - // Nothing for npm to resolve, so nothing that can lag behind the launcher. assert.isUndefined(manifest.dependencies); assert.isUndefined(manifest.optionalDependencies); - // Run what npm would install: the published tarball, unpacked. const installed = path.join(root, "installed"); yield* fs.makeDirectory(installed); const unpack = yield* run("tar", ["-xzf", output.tarball, "-C", installed], { @@ -145,7 +142,6 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { }); assert.equal(first.stdout.trim(), `stub serve a b launcher=${launcher}`, first.stderr); assert.equal(first.exitCode, 7); - // The same layout the install scripts and `t3 update` use. const versionDir = path.join(home, "runtime/versions", VERSION); assert.equal( (yield* fs.readFileString(path.join(versionDir, ".install-complete"))).trim(), @@ -157,14 +153,11 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { [VERSION], ); - // A second run reuses the download. const requestsAfterFirst = release.requests.length; const second = yield* run(process.execPath, [launcher, "--version"], { cwd: root, env }); assert.equal(second.exitCode, 7); assert.equal(release.requests.length, requestsAfterFirst); - // Services installed before 0.0.41 fork dist/bin.mjs and talk to the - // server over IPC; the channel and SIGTERM must reach the executable. const ipcServer = path.join(home, "runtime/versions", VERSION, "t3"); yield* fs.writeFileString( ipcServer, @@ -187,7 +180,6 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { assert.deepStrictEqual(await exit, [23, null]); }); - // Two first runs at once both start, and neither removes the other's install. const raceHome = path.join(root, "race-home"); const launch = run(process.execPath, [launcher, "--version"], { cwd: root, @@ -200,7 +192,6 @@ it.layer(NodeServices.layer)("build-npm-launcher", (it) => { VERSION, ]); - // A download that does not match the release checksums is never installed. release.corruptChecksum(); const corrupt = yield* run(process.execPath, [launcher, "--version"], { cwd: root, diff --git a/scripts/build-npm-launcher.ts b/scripts/build-npm-launcher.ts index 0f226a943f5e3..df1cc488814aa 100644 --- a/scripts/build-npm-launcher.ts +++ b/scripts/build-npm-launcher.ts @@ -1,16 +1,5 @@ #!/usr/bin/env node -/** - * Builds the `t3` npm package behind `npx t3` / `npm i -g t3`: a launcher - * with no dependencies that downloads this exact version's CLI archive from - * the GitHub Release on first run (scripts/npm-launcher/t3.mjs). - * The executables are not published to npm. npm's publish-time scanning held - * 250 MB platform packages back for up to an hour after the launcher went - * live, and every install in that window ran a launcher with nothing to - * launch. - * - * Output: `/t3/` (the package tree) and `/t3.tgz`, - * which is what gets published. - */ +// GitHub archives avoid npm scanning delays; output /t3/ and publishable t3.tgz. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -41,7 +30,7 @@ export class NpmLauncherCommandFailedError extends Schema.TaggedError { const hash = NodeCrypto.createHash("sha256"); // CI changes test behavior (vite.config.ts rejects .only under CI). @@ -162,12 +158,8 @@ const knipPasses = knipCheck.split(" && ").map((command, index, all) => { return { name: `knip ${index + 1}/${all.length}`, args: ["exec", "knip", ...args] }; }); -// A version bump can strand a patch in pnpm-workspace.yaml: no resolved -// version uses it and pnpm rejects the install with ERR_PNPM_UNUSED_PATCH. -// CI catches that before merge through Release Smoke, because ci-changes.cjs -// runs everything for any package.json and for root paths it does not map to -// a package (pnpm-workspace.yaml, pnpm-lock.yaml, patches/). The local run -// had no such check, so a stranded patch passed here and failed after push. +// A version bump can leave a patch unused, causing pnpm to reject the install +// with ERR_PNPM_UNUSED_PATCH. Check it here as Release Smoke does in CI. const runPatchCheck = files.some( (file) => file === "pnpm-workspace.yaml" || @@ -178,12 +170,9 @@ const runPatchCheck = files.some( NodePath.basename(file) === "package.json", ); -// Mirror Release Smoke's temp-copy install: copy the workspace manifests to -// a temp dir, omit the lockfile, and install with --lockfile-only, so the -// working tree and its lockfile stay untouched (the mobile modules are -// mobile file: dependencies, not workspace packages). Local pnpm may resolve -// from cached registry metadata, so this catches patches stranded by your -// own change, not registry drift. +// Resolve without the lockfile in a temp copy to preserve the working tree and lockfile. +// Copy mobile modules too: they are file: dependencies, not workspace packages. +// Cached registry metadata limits this to patches stranded by local changes, not registry drift. const checkPatches = (): boolean => { const temp = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-patch-check-")); try { diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 95b43ddd735af..4e016c0f554f7 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -90,6 +90,31 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("turns product analytics off unless the developer opts in", () => + Effect.gen(function* () { + const input = { + mode: "dev", + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + } as const; + const byDefault = yield* createDevRunnerEnv({ ...input, baseEnv: {} }); + const optedIn = yield* createDevRunnerEnv({ + ...input, + baseEnv: { T3CODE_TELEMETRY_ENABLED: "true" }, + }); + + assert.equal(byDefault.T3CODE_TELEMETRY_ENABLED, "false"); + assert.equal(optedIn.T3CODE_TELEMETRY_ENABLED, "true"); + }), + ); + it.effect("strips inherited service-launcher context", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ @@ -138,9 +163,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.VITE_HTTP_URL, undefined); assert.equal(env.VITE_WS_URL, undefined); assert.equal(env.T3CODE_PORT, "13773"); - // Deleting the keys is not sufficient — vite.config.ts merges - // `.env`/`.env.local` underneath this env and would revive them, so - // the intent has to be stated positively. assert.equal(env.T3CODE_SINGLE_ORIGIN_DEV, "1"); }), ); @@ -367,8 +389,6 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); }); - // `tailscale serve` config outlives the process, so a dry run that shared - // would replace and then tear down whatever mapping the port already had. it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index ff09a66e9da55..1bd88a108a4e5 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -30,10 +30,8 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; -// HTTP(S) requests to these ports are blocked by the Fetch standard before a -// browser reaches the network. Keep the complete list here so explicit or -// future wider offsets cannot produce a URL that curl accepts but browsers -// reject. https://fetch.spec.whatwg.org/#port-blocking +// Browsers block these ports before networking even when curl accepts them. +// https://fetch.spec.whatwg.org/#port-blocking const FETCH_BAD_PORTS = new Set([ 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, @@ -41,17 +39,13 @@ const FETCH_BAD_PORTS = new Set([ 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080, ]); -// Dev servers bind loopback, so loopback is the only interface whose -// availability decides whether we can use a port. +// Dev binds loopback; other interfaces cannot establish port availability. const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "::1"] as const; // A pruned migrate-dev-db copy is tens of MB; some worktrees hold a full copy // of the live database instead. const LARGE_DEV_DATABASE_BYTES = 500 * 1024 * 1024; -/** - * Loopback and the wildcards qualify; a specific interface (e.g. a LAN IP) does - * not — the OS binds only that address and the proxy target goes dark. - */ +/** Only loopback and wildcard binds keep the loopback proxy target reachable. */ export function isProxiableBindHost(host: string): boolean { const normalized = host.trim(); return ( @@ -95,10 +89,7 @@ const BUILT_DEV_ARGS = [ ] as const; type DevMode = keyof typeof MODE_ARGS; -/** - * `role` matters because only the backend honours `--host`/`T3CODE_HOST`; the - * web port is always loopback. - */ +// Only backend ports honor --host/T3CODE_HOST; web ports stay loopback. type PortAvailabilityCheck = ( port: number, role?: "server" | "web", @@ -336,21 +327,19 @@ export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(fun delete output.T3CODE_HOME; } - // A dev-runner server is never launcher-managed. When the shell that runs - // this script was itself spawned by the machine's managed t3 service (an - // agent working inside T3 Code), these leak through and the child server - // fails startup with "The service launcher started a different t3 version" - // (serviceLauncherClient.ts resolveStartup). + // Managed T3 service shells leak launcher variables that make dev servers fail version checks. + // Dev-runner servers are never launcher-managed (serviceLauncherClient.ts resolveStartup). delete output.T3_SERVICE_LAUNCHER_CONTEXT; delete output.T3_BOOT_SERVICE_UNIT; + // Each worktree has its own state, so a dev server would count as a new + // install of the developer's real accounts. Set it explicitly to opt in. + output.T3CODE_TELEMETRY_ENABLED = baseEnv.T3CODE_TELEMETRY_ENABLED ?? "false"; + if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); - // HOST is Vite's own bind address, and the desktop branch below is the - // only place we set it. An inherited one (an exported HOST, a container, - // a `HOST=0.0.0.0 npm start` habit) would otherwise reach Vite and pin - // its HMR socket to that address — see the `explicitHost` gate in - // apps/web/vite.config.ts. + // An inherited HOST pins Vite's HMR socket via explicitHost in apps/web/vite.config.ts. + // Only desktop mode should set it. delete output.HOST; if (mode === "dev" || mode === "dev:web") { // Browser dev is single-origin: everything (including /ws) is proxied diff --git a/scripts/export-android-icons.ts b/scripts/export-android-icons.ts index 9f339ef1d7743..812f24f96002c 100644 --- a/scripts/export-android-icons.ts +++ b/scripts/export-android-icons.ts @@ -1,10 +1,6 @@ #!/usr/bin/env node -// Icon Composer exports already contain a rounded-square silhouette, and Android masks -// the central 72dp of a 108dp adaptive canvas, so exporting them as a foreground produces -// a double-framed icon with the letters cropped by the mask. Instead, each variant gets a -// full-bleed background layer (the artwork behind the wordmark) and a shared transparent -// foreground that keeps the wordmark inside the safe zone. +// Android masks a 72dp safe area inside 108dp; full-bleed backgrounds and a separate wordmark avoid double silhouettes/cropping. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -41,8 +37,7 @@ const wordmarkTransform = (size: number) => { const canvasSvg = (size: number, inner: string) => `${inner}`; -// The layer sources clip to a 10pt rounded rectangle for the iOS silhouette. Android -// applies its own mask, so the layer must bleed to the canvas edge. +// Remove the iOS 10pt corner clip; Android's own mask needs full bleed. const fullBleed = (svg: string) => svg.replace(//, ''); @@ -121,8 +116,7 @@ const renderNightlyBackground = Effect.fn("androidIcons.renderNightlyBackground" repositoryRoot: string, size: number, ) { - // Positions mirror assets/nightly/app-icon.icon/icon.json. The SVG blur filter is - // dropped because Icon Composer ignores it and it smears at this raster size. + // Match assets/nightly/app-icon.icon/icon.json; omit blur, which Icon Composer ignores and rasterization smears. const clouds = [ { file: "cloud-lower-left.svg", scale: 25, translation: [-309.6375, 268.66077693836917] }, { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 58df4a6447abc..f440ebaa11902 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -68,7 +68,6 @@ function Fetch([string] $uri, [string] $destination, [switch] $progress) { Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing return } - # Read the response once, displaying actual bytes received at most ten times a second. Add-Type -AssemblyName System.Net.Http $client = New-Object System.Net.Http.HttpClient $response = $source = $file = $null @@ -117,9 +116,7 @@ if ($interactive) { } Step "Finding your release..." -# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell -# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts -# may lack. +# PROCESSOR_ARCHITEW6432 bypasses WOW64 emulation; 5.1 hosts can lack RuntimeInformation's required .NET version. $rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } $arch = switch ($rawArch) { "AMD64" { "x64" } @@ -130,9 +127,6 @@ $arch = switch ($rawArch) { $channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } $version = $env:T3CODE_VERSION if (-not $version) { - # Tags are v; the channel is the prerelease identifier, or none for - # stable. Only tags of the requested train are considered, so a stable - # install can never pick up a nightly or preview build by accident. $tagPattern = switch ($channel) { "stable" { '^v\d+\.\d+\.\d+$' } "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } @@ -193,7 +187,6 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { $global:ProgressPreference = "SilentlyContinue" Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force } finally { $global:ProgressPreference = $savedProgress } - # The archive wraps everything in one directory named after its stem. Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force diff --git a/scripts/install.sh b/scripts/install.sh index 1718aff952eda..20a0c0251531d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -145,9 +145,6 @@ fi channel="${T3CODE_CHANNEL:-stable}" version="${T3CODE_VERSION:-}" if [ -z "$version" ]; then - # Tags are v; the channel is the prerelease identifier, or none for - # stable. Only tags of the requested train are considered, so a stable - # install can never pick up a nightly or preview build by accident. case "$channel" in stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; diff --git a/scripts/lib/build-target-arch.ts b/scripts/lib/build-target-arch.ts index 67e379c202ab9..bf80fbfb5966a 100644 --- a/scripts/lib/build-target-arch.ts +++ b/scripts/lib/build-target-arch.ts @@ -33,8 +33,7 @@ const resolveHostProcessArch = Effect.fn("resolveHostProcessArch")(function* () if (processArch === "x64") { if (platform !== "win32") return "x64"; - // On Windows-on-Arm, x64 Node/Bun can run under emulation while the host - // still reports ARM64 via the processor environment variables. + // Emulated x64 Node/Bun can run on Windows ARM64; use host processor variables. const env = yield* WindowsProcessorArchitectureConfig; return ( normalizeWindowsArch(optionToUndefined(env.processorArchitectureW6432)) ?? diff --git a/scripts/lib/cli-executable-imports.ts b/scripts/lib/cli-executable-imports.ts index d70afb5a053f9..f86e7b7933e78 100644 --- a/scripts/lib/cli-executable-imports.ts +++ b/scripts/lib/cli-executable-imports.ts @@ -2,13 +2,7 @@ import * as NodeModule from "node:module"; import ts from "typescript-legacy"; -/** - * Inside a Node single-executable, `import` statements and `import()` can only - * resolve built-in modules; any file-backed specifier throws at module - * evaluation (static) or at first use (dynamic). External packages therefore - * have to be reached through `createRequire`, which reads the real filesystem - * in every runtime. - */ +/** Node single-executable imports resolve only builtins; use createRequire for file-backed external packages. */ export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray { const specifiers = new Set(); const module = ts.createSourceFile( @@ -29,9 +23,7 @@ export function findEsmImportsOfExternalPackages(source: string): ReadonlyArray< : undefined; if (specifierNode && ts.isStringLiteralLike(specifierNode)) { const specifier = specifierNode.text; - // Multi-runtime SDKs can retain optional Bun imports. Those are runtime - // built-ins, not packages to stage beside the executable. Static imports - // still fail here because Node would evaluate them unconditionally. + // Optional Bun imports are runtime builtins; static Bun imports still fail under Node. const runtimeBuiltin = NodeModule.isBuiltin(specifier) || (dynamic && specifier.startsWith("bun:")); if (!runtimeBuiltin && !specifier.startsWith("./") && !specifier.startsWith("../")) { diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index d74efae4c16f3..729975d70b1a2 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -15,10 +15,7 @@ import { shouldBundleCliDependency, } from "./cli-external-packages.ts"; -// optionalDependencies matter as much as dependencies here: every native family -// in the list declares its actual platform bindings there (ffi-rs -> @yuuang/*, -// fff-node -> @ff-labs/fff-bin-*), so reading only `dependencies` would check -// nothing for exactly those packages. +// Native bindings live in optionalDependencies; checking dependencies alone misses them. const PackageManifest = Schema.Struct({ dependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), optionalDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), @@ -49,11 +46,7 @@ describe("shouldBundleCliDependency", () => { }); it.layer(NodeServices.layer)("external package dependency closure", (it) => { - // Read manifests off disk from the pnpm store rather than resolving them. - // `require("/package.json")` cannot do this job: under pnpm isolation a - // transitive package (node-addon-api, ffi-rs) is not reachable by name from - // this file at all, and an `exports` map can refuse the `/package.json` - // subpath outright (@ff-labs/fff-node). + // Read the pnpm store directly: isolation hides transitive names and exports maps can block package.json. const readInstalledPackages = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -62,8 +55,7 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { "../../node_modules/.pnpm", ); - // The store holds regular files too (lock.yaml), so a path built under one - // raises ENOTDIR rather than reporting absence. + // Paths under store files such as lock.yaml raise ENOTDIR, not absence. const isPresent = (candidate: string) => fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); @@ -92,8 +84,6 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { return installed; }).pipe(Effect.cached, Effect.runSync); - // Runtime-external only. The build-only entries resolve `bun:*` and are never - // loaded by Node, so their closure genuinely does not need to be external. const isRuntimeExternal = isRuntimeExternalCliDependency; it.effect("keeps every runtime dependency of an external package external too", () => diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index 678d3d6e91519..b6088ee1df023 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -1,16 +1,5 @@ -/** - * Two consumers derive from this list, and they must never disagree: - * - * - apps/server/vite.config.ts decides what stays external to the bundle. - * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for - * the Windows server sidecar. - * - */ -/** - * An external package is loaded from the real filesystem, so its own `require` - * also resolves from the real filesystem; a dependency that was bundled away - * exists only inside the emitted bundle and is unreachable there. - */ +/** Keep bundler externals and staged runtime dependency roots aligned. */ +// Filesystem-loaded externals need dependencies outside the bundle. export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ // Cursor ships computed Webpack imports and platform helper packages. "@cursor/sdk", @@ -28,8 +17,7 @@ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "utf-8-validate", ] as const; -// These are Cursor's disk-backed dependency closure. Match package boundaries -// so "zod" does not also externalize unrelated packages such as zod-to-json-schema. +// Match Cursor dependency boundaries so zod does not externalize zod-to-json-schema. const CURSOR_RUNTIME_DEPENDENCIES = [ "@bufbuild/protobuf", "@connectrpc/connect", @@ -49,12 +37,7 @@ export function isRuntimeExternalCliDependency(id: string): boolean { ); } -/** - * This has to be wired to the bundler's `neverBundle`, not just to - * `alwaysBundle`. `alwaysBundle` only forces packages IN — returning false from - * it means "no opinion", and the default then applies: a declared dependency - * stays external, but a transitive one gets bundled. - */ +/** Use neverBundle: false from alwaysBundle means no opinion and leaves transitive packages bundled. */ export function isExternalCliDependency(id: string): boolean { return isRuntimeExternalCliDependency(id); } @@ -72,10 +55,7 @@ export function selectCliRuntimeExternalDependencies( ); } -/** - * `regionCount` is reported so the caller can tell "nothing was inlined" apart - * from "the marker format changed and this scan no longer sees anything". - */ +/** regionCount distinguishes a clean bundle from a changed marker format the scan cannot see. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; readonly inlined: ReadonlyArray; diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts index d22e7bd42ec0c..b7dff1c2bad9a 100644 --- a/scripts/lib/desktop-external-packages.ts +++ b/scripts/lib/desktop-external-packages.ts @@ -1,20 +1,12 @@ -/** - * Both `apps/desktop/vite.config.ts` and the artifact stage in - * scripts/build-desktop-artifact.ts derive from this list, so a package that is - * external is also the only kind of package the staged production install - * carries. - */ +/** Keep bundler externals aligned with the staged production dependency list. */ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ - // Native addons and the wrappers that dlopen them by real path. "@napi-rs/keyring", "@crowecawcaw/xa11y", "@clerk/electron-passkeys", "electron-webauthn", "ffi-rs", "@yuuang/", - // Reads its own bundle from disk by resolving `playwright-core/package.json` - // at runtime and ships the browser driver alongside; there is nothing to - // gain from inlining a 10 MB file the code re-reads as text. + // Playwright reads its runtime bundle/driver from disk; inlining cannot replace those files. "playwright-core", ] as const; diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index c37a094b81199..9f4ac5212e284 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -1,8 +1,4 @@ -/** - * Because browser dev is single-origin (Vite proxies the backend — see - * `resolveDevProxyTarget` in apps/web/vite.config.ts), one proxy rule covering - * the web port is enough; the backend needs no mapping of its own. - */ +/** Vite proxies the backend, so only the web port needs a share mapping. */ import { buildTailscaleHttpsBaseUrl, @@ -16,11 +12,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { ChildProcessSpawner } from "effect/process"; -/** - * Human-readable gloss for each diagnostic. Deliberately our own words rather - * than the CLI's: tailscale prints auth keys and node names into stderr, and - * this string is logged. - */ +/** Log fixed diagnostics; tailscale stderr can contain auth keys and node names. */ const DIAGNOSTIC_EXPLANATIONS: Record = { "no-existing-handler": "no mapping existed for that port", "not-logged-in": "this machine is not logged into a tailnet — run `tailscale up`", @@ -87,11 +79,7 @@ export type DevShareError = | TailnetNameMissingError | DevServeFailedError; -/** - * Runs uninterruptibly: this is called from a finalizer on the way out of an - * interrupted program, and cancelling the cleanup subprocess would leave - * exactly the stale mapping it exists to remove. - */ +/** Finalizer cleanup must be uninterruptible or cancellation can leave stale mappings. */ export const unshareDevServer = ( webPort: number, ): Effect.Effect< @@ -145,9 +133,7 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in const cleared = yield* unshareDevServer(input.webPort); if (!cleared.cleared) { - // Serving over routes we failed to remove would hand out a URL that is - // broken in a way the user cannot see: the page loads while /ws and /api - // silently resolve to a dead backend. Better to refuse and say why. + // Refuse stale routes: the page could load while /ws and /api still point at a dead backend. return yield* new DevServeFailedError({ stage: "clear-existing", webPort: input.webPort, @@ -156,10 +142,7 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in }); } - // Proxy to the hostname Vite binds rather than the package default of - // 127.0.0.1. Vite listens on `localhost`, which Node 17+ resolves to `::1` - // first, so it only binds the IPv6 loopback and a 127.0.0.1 target has - // nothing behind it (tailscale answers 502). + // Vite's localhost can bind only ::1; a 127.0.0.1 proxy target would return 502. yield* ensureTailscaleServe({ localPort: input.localTarget?.port ?? input.webPort, servePort: input.webPort, diff --git a/scripts/lib/third-party-licenses.test.ts b/scripts/lib/third-party-licenses.test.ts index c2ee354a8bc1d..6db2d4a5b857a 100644 --- a/scripts/lib/third-party-licenses.test.ts +++ b/scripts/lib/third-party-licenses.test.ts @@ -232,7 +232,6 @@ describe("third-party license generation", () => { name: "fixture-app", dependencies: { "demo-dependency": "1.2.3", "@types/demo": "1.0.0" }, }); - // The DefinitelyTyped shape, and the csstype / undici-types one it pulls in. await write("@types/demo", { main: "", types: "index.d.ts", @@ -260,7 +259,6 @@ describe("third-party license generation", () => { it.each([ { shape: "a JS entry next to its declarations", - // The @shikijs/types shape: a types condition next to a real JS entry. entry: { main: "./dist/index.mjs", exports: { ".": { types: "./dist/index.d.mts", import: "./dist/index.mjs" } }, diff --git a/scripts/lib/third-party-licenses.ts b/scripts/lib/third-party-licenses.ts index d83487416a287..ed373f99c217b 100644 --- a/scripts/lib/third-party-licenses.ts +++ b/scripts/lib/third-party-licenses.ts @@ -535,7 +535,6 @@ function exportTargets(value: unknown, underTypesCondition = false): ReadonlyArr ); } -/** Declarations, or the package's own manifest that many packages also export. Neither is code. */ function isTypeDeclaration(target: ExportTarget): boolean { return ( target.underTypesCondition || @@ -558,11 +557,7 @@ async function fileExists(path: string): Promise { } } -/** - * A package that only describes types (DefinitelyTyped, csstype, undici-types): it declares types - * and has no JS entry, so nothing of it ships. A types condition or a name with "types" in it is not - * enough on its own: `@shikijs/types` exports real code next to its declarations. - */ +/** Type declarations plus no JS entry mean nothing ships; a types name/condition alone does not prove that. */ async function isTypeOnlyPackage(packageJson: PackageJson, packageRoot: string): Promise { const targets = exportTargets(packageJson.exports); const declaresTypes = diff --git a/scripts/lib/windows-jsign.ts b/scripts/lib/windows-jsign.ts index 9b3d1fcfcfe60..ad95aebadb863 100644 --- a/scripts/lib/windows-jsign.ts +++ b/scripts/lib/windows-jsign.ts @@ -1,8 +1,6 @@ -// Azure Trusted Signing from a non-Windows host. The Windows-only path is -// electron-builder's azureSignOptions (PowerShell + signtool.exe); this one is -// electron-builder's `win.signtoolOptions.sign` hook and the CLI archive's -// signer. jsign reads AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET -// itself when no --storepass is given. +// Azure Trusted Signing from a non-Windows host, for electron-builder's +// `win.signtoolOptions.sign` hook and the CLI archive. jsign reads AZURE_TENANT_ID, +// AZURE_CLIENT_ID and AZURE_CLIENT_SECRET itself when no --storepass is given. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Config from "effect/Config"; diff --git a/scripts/mobile-native-client.test.ts b/scripts/mobile-native-client.test.ts index 0b9734a182f38..b39294c817983 100644 --- a/scripts/mobile-native-client.test.ts +++ b/scripts/mobile-native-client.test.ts @@ -8,8 +8,6 @@ import { NativeClientError, prebuildAndroid } from "./mobile-native-client.ts"; const caches = [".gradle/history", "app/.cxx/object"]; -// A generated Android project with Gradle and native outputs, and a stand-in for -// `expo prebuild --clean`, which deletes the directory and writes fresh sources. const fixture = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index e16a0af464e6b..ba30f4db33d68 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -283,13 +283,7 @@ export const installedBinary = Effect.fn("installedBinary")(function* ( return yield* digest(hashes.sort().join("\n")); }); -/** - * Run `expo prebuild --clean` without losing `android/.gradle` (task history) and - * `android/app/.cxx` (native compile outputs). Both invalidate by input, so a clean - * rebuild only redoes changed work. `app/build` stays out: its autolinking and JS - * bundle tasks do not declare every input. Each step is best-effort, because a lost - * cache costs one slower build and must never cost the build. - */ +/** Preserve input-invalidated Gradle/.cxx caches; exclude app/build because its tasks omit inputs. Ignore cache failures. */ export const prebuildAndroid = Effect.fn("nativeClient.prebuildAndroid")(function* ( mobile: string, prebuild: Effect.Effect, @@ -297,8 +291,7 @@ export const prebuildAndroid = Effect.fn("nativeClient.prebuildAndroid")(functio const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const android = path.join(mobile, "android"); - // A sibling of android/ keeps the renames on one filesystem. It is gitignored, and a - // run killed mid-prebuild leaves it for the next run to clear. + // Sibling renames stay on one filesystem; interrupted runs leave this gitignored cache for next-run cleanup. const saved = path.join(mobile, ".android-cache"); const caches = [".gradle", "app/.cxx"]; const clear = fs.remove(saved, { recursive: true, force: true }).pipe(Effect.ignore); diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 22d127bd3728a..0291eb4b4eb42 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -390,10 +390,7 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -// V1 tables this seed owns. `projection_projects` is not listed: V2 still -// stores projects there, so the seed upserts its own rows instead. V2 clients -// do not read the V1 thread rows; moving the seed to V2 is tracked in -// https://github.com/pingdotgg/t3code/issues/15013. +// V2 still owns projection_projects; upsert project rows without deleting them with V1 thread data. const SEEDED_V1_TABLES = [ "projection_thread_messages", "projection_thread_sessions", @@ -447,10 +444,7 @@ function seedDatabase( threads: ReadonlyArray<(typeof SHOWCASE_THREADS)[number]>, now: number, ): void { - // The environment server is already running against this file and keeps - // writing (migrations, projections) while we seed, so the write lock is - // genuinely contended — without a busy timeout `BEGIN IMMEDIATE` fails - // instantly with SQLITE_BUSY on a loaded machine. + // The running server writes migrations/projections concurrently; wait for its lock instead of failing SQLITE_BUSY. const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); @@ -526,9 +520,7 @@ function seedDatabase( database.exec("COMMIT"); } catch (error) { - // A failed BEGIN (or an error SQLite already auto-rolled back) leaves no - // transaction, and the rollback's own "cannot rollback" error would then - // replace the one that actually explains the failure. + // Failed BEGIN or automatic rollback leaves no transaction; preserve the original error if rollback fails. try { database.exec("ROLLBACK"); } catch {} @@ -579,8 +571,7 @@ export async function seedShowcaseEnvironment(input: { }); }), ); - // The environment server begins listening before it finishes migrating the - // database, so wait for the schema before deleting from and reseeding it. + // Listening precedes migration completion; wait for the schema before reseeding. await waitForSeedableSchema(dbPath); seedDatabase(dbPath, workspaceRoots, projects, threads, now); diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index e8ec384924a8a..2111ce85f113f 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -79,8 +79,7 @@ export function resolveShowcaseAndroidAbi( const config: ShowcaseConfig = { outputDirectory: "artifacts/app-store/screenshots", - // Dedicated port so the harness cannot attach to a normal mobile dev server - // (or a second worktree) and capture the wrong bundle. + // A dedicated port prevents captures from attaching to another dev server's bundle. metroPort: 8199, settleDelayMs: 2_500, devices: [ @@ -146,8 +145,7 @@ const config: ShowcaseConfig = { appearance: "dark", theme: DEFAULT_SHOWCASE_THEME, orientation: "landscape", - // The lock screen does not follow the app's self-rotation, so a - // headless landscape capture of it would come out sideways. + // Lock screens ignore app rotation; headless landscape captures would be sideways. scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", @@ -162,8 +160,7 @@ const config: ShowcaseConfig = { id: "pixel", platform: "android", avd: "Pixel_10_Pro", - // Apple Silicon uses ARM64 locally; CI overrides this with x86_64 so its - // Blacksmith Linux runner can use KVM acceleration. + // Apple Silicon defaults to ARM64; Blacksmith Linux CI overrides to x86_64 for KVM. abi: resolveShowcaseAndroidAbi(process.env.T3_SHOWCASE_ANDROID_ABI), appearance: "dark", theme: DEFAULT_SHOWCASE_THEME, diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 1182caac1cfdc..9087ebc6ffce0 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -34,8 +34,7 @@ const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); const ANDROID_PACKAGE = "com.t3tools.t3code"; const APP_SCHEME = "t3code"; -// expo-dev-launcher reads these off the manifest URL and updates the dev menu -// preferences before the app loads, keeping captures free of dev chrome. +// Manifest flags update expo-dev-launcher preferences before loading, hiding dev chrome in captures. const DEV_CLIENT_LAUNCH_FLAGS = "disableOnboarding=1&disableFab=1&disableAutoLaunch=1"; const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; const SERVER_HOST = "0.0.0.0"; @@ -65,8 +64,7 @@ const MOBILE_BUILD_ENV = { ANDROID_HOME: ANDROID_SDK_ROOT, APP_VARIANT: "production", EXPO_NO_GIT_STATUS: "1", - // Lets the capture build require full screen on iPad so the app can rotate - // itself to landscape (see app.config.ts). + // iPad self-rotation needs the capture build's fullscreen mode (app.config.ts). T3_SHOWCASE_CAPTURE_BUILD: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? @@ -338,8 +336,7 @@ export function parseShowcaseCliArgs(args: ReadonlyArray): CliOptions { } else if (SHOWCASE_THEMES.some((theme) => theme === value)) { themes.add(value as ShowcaseTheme); } else { - // The app silently falls back to its default palette for an unknown id, - // so reject it here rather than shipping a mislabeled screenshot. + // Unknown themes silently use the default palette; reject mislabeled captures. throw new Error(`Unsupported theme '${value}'. Use ${SHOWCASE_THEMES.join(", ")}, or all.`); } index += 1; @@ -809,11 +806,7 @@ async function iosSimulatorDataPath(udid: string): Promise { return dataPath; } -// generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up -// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it on -// every boot until the user dismisses it. Stamping the readiness marker before -// boot makes the daemon skip the post, and dropping the CoreFollowUp store -// clears a banner that a previous boot already queued. +// Apple Intelligence posts persistent first-boot banners; mark readiness and clear queued CoreFollowUp notices. async function suppressIosSystemFollowUps(udid: string): Promise { const dataPath = await iosSimulatorDataPath(udid); const preferences = NodePath.join(dataPath, "Library/Preferences"); @@ -838,9 +831,7 @@ async function suppressIosSystemFollowUps(udid: string): Promise { async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise { await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]); - // Always-on displays (Pro Max) dim a locked screen instead of turning it - // off, which the lock-screen wake cannot tell from a lit one. Without it the - // locked display goes dark and the wake lights it fully. + // Disable always-on dimming so lock-screen wake can distinguish off from fully lit. await runCommand("xcrun", [ "simctl", "spawn", @@ -870,9 +861,7 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin ]); } -// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed -// portrait compatibility window, which defeats the in-app landscape rotation -// the capture build relies on. +// iPadOS 26 windowing forces fullscreen apps into portrait, defeating capture-build rotation. async function ensureIosFullScreenAppsMode(udid: string): Promise { const current = await commandOutput("xcrun", [ "simctl", @@ -884,8 +873,7 @@ async function ensureIosFullScreenAppsMode(udid: string): Promise { "SBChamoisWindowingEnabled", ]).catch(() => ""); if (current.trim() === "0") return; - // The Settings toggle writes all three keys; SBChamoisWindowingEnabled - // alone is not honored on a freshly created device. + // Fresh devices honor the Settings toggle only when all three keys are written. for (const key of [ "SBChamoisWindowingEnabled", "SBMedusaMultitaskingEnabled", @@ -903,9 +891,7 @@ async function ensureIosFullScreenAppsMode(udid: string): Promise { "false", ]); } - // A SpringBoard restart is not enough on a freshly created simulator (the - // first CI run captured with windowing still active), so reboot the device - // and verify the mode actually stuck. + // New simulators require a reboot, not just SpringBoard restart, before windowing settings take effect. await runCommand("xcrun", ["simctl", "shutdown", udid]); await runCommand("xcrun", ["simctl", "boot", udid]); await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]); @@ -978,10 +964,7 @@ async function iosSimulatorLocked(udid: string): Promise { return state.trim().endsWith(" 1"); } -/** - * The staging app asks for notification permission; simctl has no privacy - * service for it, so answer the prompt until the scene reports ready. - */ +// simctl has no notification privacy service; answer the staging app's prompt until ready. async function allowIosNotificationsUntilReady(udid: string, ready: Promise): Promise { const state = { settled: false }; const tapping = (async () => { @@ -1025,11 +1008,7 @@ async function presentIosLockScreen(udid: string): Promise { await runAxe(udid, ["tap", "--label", "Allow"]).catch(() => undefined); } -/** - * The alert usually wakes the display, but not always. A home press on a dark - * display only wakes it, so press only after a screenshot proves it is dark; - * pressing on a lit lock screen would unlock the device instead. - */ +/** Press home only if a screenshot proves the lock screen is dark; a lit display would unlock instead. */ async function wakeIosLockScreen(udid: string): Promise { const probe = NodePath.join(NodeOS.tmpdir(), `t3-showcase-wake-${udid}.png`); try { @@ -1140,8 +1119,7 @@ async function captureIos( firstScene, "--showcaseTheme", capture.theme, - // The app rotates itself; Simulator menu UI scripting needs macOS - // Accessibility permission that CI runners do not grant to osascript. + // Simulator rotation UI scripting needs Accessibility permission unavailable on CI. "--showcaseOrientation", capture.device.orientation ?? "portrait", ]); @@ -1162,9 +1140,7 @@ async function captureIos( for (let attempt = 0; attempt < 2; attempt += 1) { const isLastAttempt = attempt === 1; try { - // A freshly installed Expo development build can spend well over 30s - // applying an already-bundled update after it reaches 100%. Killing it - // at that point sends the next capture back to the dev launcher. + // A fresh Expo build can spend over 30 s applying a bundled update; killing it returns to the launcher. await waitForScene(120_000); break; } catch (error) { @@ -1190,9 +1166,7 @@ async function captureIos( destination, ]); if (capture.device.orientation === "landscape") { - // A headless simulator keeps its display portrait while the rotated app - // renders sideways inside it; with Simulator.app attached the display - // itself rotates. Only post-rotate the former. + // Headless displays stay portrait despite app rotation; post-rotate only captures still in portrait. const { width, height } = readPngDimensions(await NodeFSP.readFile(destination)); if (height > width) { await runCommand("sips", ["--rotate", capture.device.fold ? "90" : "270", destination]); @@ -1215,11 +1189,7 @@ async function runAdb(serial: string, args: ReadonlyArray): Promise { const keys = (await adbOutput(serial, ["shell", "cmd", "notification", "list"])) .split("\n") @@ -1353,9 +1323,7 @@ async function writeAndroidShowcaseScene(serial: string, scene: ShowcaseScene): ]); } -// Gesture and key-command toggles have no launch-URL flag, so they still go -// through the preferences file; onboarding, auto-launch and the floating button -// come from DEV_CLIENT_LAUNCH_FLAGS on the launch URL. +// Gesture/key-command toggles have no URL flags; set preferences separately from DEV_CLIENT_LAUNCH_FLAGS. async function prepareAndroidShowcaseApp(serial: string): Promise { const preferences = ` diff --git a/scripts/notify-discord-release.ts b/scripts/notify-discord-release.ts index 105898e0b9c56..0eb3bf348530a 100644 --- a/scripts/notify-discord-release.ts +++ b/scripts/notify-discord-release.ts @@ -198,8 +198,7 @@ export const buildDiscordReleaseAnnouncement = ( ? suppressMentions(formatReleaseNotes(options.releaseNotes?.trim() ?? "")) : ""; const description = notes ? `${intro}\n\n${notes}` : intro; - // One embed per message avoids Discord's URL deduplication. Reserve a full - // title and the version footer within the 6000-character aggregate limit. + // Separate embeds avoid URL deduplication; reserve title and footer space within Discord's 6000-character limit. const limit = Math.min(4096, 6000 - 256 - footerText.length); const descriptions = splitDescription(description, limit); diff --git a/scripts/npm-launcher/bin.mjs b/scripts/npm-launcher/bin.mjs index d328f69567b14..8c07fda86b4ec 100644 --- a/scripts/npm-launcher/bin.mjs +++ b/scripts/npm-launcher/bin.mjs @@ -1,2 +1,2 @@ -// Background services installed before 0.0.41 start the npm package from this path. +// Legacy background services still enter the npm package through this path. import "../bin/t3.mjs"; diff --git a/scripts/npm-launcher/t3.mjs b/scripts/npm-launcher/t3.mjs index cc866746669ec..4973cf10a423a 100644 --- a/scripts/npm-launcher/t3.mjs +++ b/scripts/npm-launcher/t3.mjs @@ -1,10 +1,5 @@ #!/usr/bin/env node -// The `t3` npm package. Downloads this version's CLI archive from the GitHub -// Release into /runtime/versions/ (the folder the install -// scripts and `t3 update` use), verifies it against the release's SHA256SUMS, -// then runs it. `dist/bin.mjs` re-enters here for background services -// installed before 0.0.41, which start the npm package with Node and talk to -// it over an IPC channel. +// Share the installer's runtime cache; pre-0.0.41 services re-enter here over IPC. import { spawn, spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; @@ -36,7 +31,6 @@ async function get(url) { return response; } -/** Downloads `url` to `file` and returns its sha256, showing progress on a terminal. */ async function download(url, file) { const response = await get(url); const total = Number(response.headers.get("content-length")) || 0; @@ -80,8 +74,7 @@ async function install(dir, key) { .find((m) => m && m[2] === archive); if (!line) throw new Error(`${archive} is not listed in the release's SHA256SUMS`); - // Extract beside the final folder and rename it into place, so an - // interrupted download never leaves a half-installed version behind. + // Stage beside the destination and rename atomically to avoid half-installed versions. fs.mkdirSync(path.dirname(dir), { recursive: true }); const staging = fs.mkdtempSync(`${dir}.staging-`); try { @@ -112,13 +105,7 @@ const isComplete = (dir) => { } }; -/** - * Moves a finished staging folder to `dir`. Rename never replaces an existing - * folder, so a complete install another launcher (or `t3 update`) published - * first is kept, never deleted from under a running process. Only a folder - * without the completion marker, left by an interrupted older installer, is - * cleared before trying again. - */ +/** Preserve concurrently published complete installs; replace only incomplete folders without the completion marker. */ function publish(staging, dir) { try { fs.renameSync(staging, dir); @@ -141,9 +128,7 @@ async function main() { const dir = path.join(t3Home(), "runtime", "versions", version); if (!isComplete(dir)) await install(dir, key); - // A service launcher talks to its server over IPC; pass that channel and - // signals through. In a terminal, Ctrl-C already reaches the child directly, - // so forwarding SIGINT too would deliver it twice. + // Service IPC and signals must reach the child; terminal Ctrl-C already does, so do not forward SIGINT twice. const ipc = process.send !== undefined; const child = spawn( path.join(dir, process.platform === "win32" ? "t3.exe" : "t3"), diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index fc82d4e20d6ed..5819aa5cd5d87 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -17,6 +17,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/mobile/package.json", + "apps/mobile/modules/t3-highlights/package.json", "apps/mobile/modules/t3-source-text/package.json", "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json", diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index a82f2b2380f29..100a7af168105 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -46,12 +46,9 @@ if (!projectRoot) { throw new Error("T3CODE_PROJECT_ROOT is not set. Run this through the t3.json setup action."); } -// `shell` resolves `vp` through PATH, including Windows command shims. -// Setup has no one answering install prompts. Pass pnpm's confirmation flag -// through vp and leave stdin closed so other prompts fail instead of hanging. -// Install exactly the locked dependencies so setup never rewrites the lockfile. -// A stale lockfile must fail setup rather than silently resolving a new one. -// Output is piped, not inherited, so a failure can keep its tail in the marker. +// Use the shell for Windows vp shims. Unattended installs need pnpm's confirmation +// flag and closed stdin so other prompts fail. A stale lockfile must fail, not be rewritten. +// Pipe output so the failure marker can keep its tail. const install = NodeChildProcess.spawn("vp i --frozen-lockfile -- --yes", { cwd: worktree, shell: true, @@ -102,9 +99,7 @@ if ( ); } -// Env files live as real files in the main checkout; worktrees only get -// symlinks to them. Only a symlink is ever replaced, so a real env file is -// never deleted, including when this runs in the main checkout itself. +// Worktrees link main-checkout env files; replace only symlinks so real files always survive. for (const file of ENV_FILES) { const source = NodePath.join(projectRoot, file); const sourceStat = NodeFS.lstatSync(source, { throwIfNoEntry: false }); diff --git a/scripts/smoke-cli-archive.ts b/scripts/smoke-cli-archive.ts index 7c994153469bc..c5344bdeb43fe 100644 --- a/scripts/smoke-cli-archive.ts +++ b/scripts/smoke-cli-archive.ts @@ -46,8 +46,7 @@ const runExecutable = Effect.fn("runExecutable")(function* ( const child = yield* spawner.spawn( ChildProcess.make(executable, args, { cwd, - // Empty PATH: the archive must not reach a system node, and the - // launcher context must not leak in from a developer shell. + // Isolate system Node and inherited launcher context from the archive smoke check. env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd }, extendEnv: false, }), @@ -68,9 +67,7 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { const platform = yield* HostProcess.Platform; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" }); - // Windows can keep t3.exe locked (EBUSY) for a moment after the server - // exits. A leftover scratch directory on a CI runner is harmless, so - // cleanup retries briefly and never fails a smoke test that passed. + // Windows can briefly retain executable locks; cleanup retries but must not fail a successful smoke check. yield* Effect.addFinalizer(() => fs.remove(scratch, { recursive: true }).pipe( Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }), @@ -80,8 +77,7 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { ), ); - // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU - // tar; use the bsdtar Windows ships, which reads both formats. + // Windows bsdtar reads zip; Git Bash's GNU tar does not. const tar = platform === "win32" ? windowsSystemTar() : "tar"; const extract = yield* spawner .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch])) @@ -134,6 +130,9 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { TMPDIR: scratch, TEMP: scratch, T3CODE_HOME: home, + // The archive has a release channel built in, so its events would + // look like a real new install. + T3CODE_TELEMETRY_ENABLED: "false", }, extendEnv: false, }, @@ -143,8 +142,7 @@ const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: { Effect.all([collect(server.stdout), collect(server.stderr)]), ); const httpClient = yield* HttpClient.HttpClient; - // A request that connects while the server is still initializing can hang, - // so each probe gets its own deadline, like the SSH readiness probe. + // Probes can hang during server initialization; bound each request as SSH readiness does. const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe( Effect.map((response) => response.status === 200), Effect.timeout(Duration.seconds(2)), diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 180a7bb3b1510..544ed7ad39b11 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -160,6 +160,22 @@ "noticeFile": "apps/mobile/modules/t3-composer-editor/LICENSE", "sourceUrl": "https://github.com/expo/expo" }, + { + "bundles": ["ios", "android", "mobile"], + "license": "Apache-2.0", + "name": "wasm2c runtime (WABT)", + "version": "1.0.42", + "noticeFile": "apps/mobile/modules/t3-highlights/Vendor/wasm2c/LICENSE", + "sourceUrl": "https://github.com/WebAssembly/wabt/tree/1.0.42/wasm2c" + }, + { + "bundles": ["ios", "android", "mobile"], + "license": "MIT", + "name": "SIMD Everywhere", + "version": "0.8.2", + "noticeFile": "apps/mobile/modules/t3-highlights/Vendor/simde/LICENSE", + "sourceUrl": "https://github.com/simd-everywhere/simde/tree/v0.8.2" + }, { "bundles": ["ios", "android", "mobile"], "license": "MIT", diff --git a/vite.config.ts b/vite.config.ts index 41d4574187da1..3612239a970a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import * as NodeURL from "node:url"; +import { remoteCacheUrl } from "./infra/task-cache/src/remoteCache.ts"; const DOM_TEST_ENVIRONMENT_ALLOWLIST: Array = []; @@ -39,6 +40,12 @@ const RESTRICTED_IMPORT_PATHS = [ importNames: ["CodeView"], message: "Use StyledDiffCodeView so web diff surfaces share styling and virtualized geometry.", }, + { + name: "react-native-screens", + importNames: ["ScrollViewMarker"], + message: + "Use NativeScrollViewMarker or NativeScrollView (src/native/NativeScrollViewMarker.tsx): they carry the header's scroll-edge blur under the Duo's vertical bar.", + }, ]; const RESTRICTED_UI_VARIANT_PATTERNS = [ @@ -72,7 +79,17 @@ const RESTRICTED_PULL_REQUEST_GLYPH_IMPORTS = { "Pick a glyph by meaning from PullRequestGlyph in apps/web/src/components/pullRequest/pullRequestIcons.tsx so every surface draws the same pull request the same way.", }; +// Vite+ supports remote configuration before its published types include it. +const cacheUrl = remoteCacheUrl(import.meta.dirname); +const taskCacheConfig = { + scripts: false, + ...(cacheUrl ? { remote: { url: cacheUrl } } : {}), +}; + export default defineConfig({ + run: { + cache: taskCacheConfig, + }, resolve: { alias: { "~": NodeURL.fileURLToPath(new URL("./apps/web/src", import.meta.url)), @@ -80,6 +97,8 @@ export default defineConfig({ }, test: { environment: "node", + // Fixture subprocesses must not inherit the outer task runner’s cache policy. + env: { VP_REMOTE_CACHE: "off", VP_REMOTE_CACHE_URL: "" }, exclude: TEST_EXCLUDE, hookTimeout: 60_000, testTimeout: 60_000, @@ -135,8 +154,8 @@ export default defineConfig({ fmt: { ignorePatterns: [ ".repos/**", - // Macroscope's glob-per-line ignore grammar, not Markdown: formatting - // it rewrites `*` as `_` and joins lines. + "infra/task-cache/vendor/**", + // Macroscope requires one glob per line; Markdown formatting corrupts it. ".macroscope/ignore.md", ".alchemy", "dist", @@ -151,6 +170,9 @@ export default defineConfig({ "apps/mobile/android/**", "apps/mobile/ios/**", "apps/mobile/uniwind-types.d.ts", + // Vendored and generated C. + "apps/mobile/modules/t3-highlights/Vendor/**", + "apps/mobile/modules/t3-highlights/cpp/generated/**", "*.icon/**", ], sortPackageJson: {}, @@ -167,6 +189,7 @@ export default defineConfig({ ignorePatterns: [ ".repos", ".repos/**", + "infra/task-cache/vendor/**", "dist", "dist-electron", "node_modules", @@ -426,7 +449,10 @@ export default defineConfig({ }, { files: ["apps/mobile/src/**"], - rules: { "t3code/no-mobile-uniwind-theme-escape-hatches": "error" }, + rules: { + "t3code/no-mobile-uniwind-theme-escape-hatches": "error", + "t3code/require-registered-list-scroll": "error", + }, }, { files: ["apps/mobile/src/**"], @@ -438,8 +464,12 @@ export default defineConfig({ rules: { "t3code/no-mobile-pull-request-glyph-literals": "error" }, }, { - // React commits state set in a raw ResizeObserver callback after the paint, so derived - // layout lands a frame late. observeResize flushes every observed resize in one render. + files: ["apps/mobile/src/**"], + excludeFiles: ["**/*.test.{ts,tsx}"], + rules: { "t3code/no-ambiguous-detail-navigation": "error" }, + }, + { + // Raw ResizeObserver state commits miss the paint; observeResize flushes derived layout together. files: ["apps/web/src/**"], excludeFiles: [ "apps/web/src/lib/observeResize.ts", @@ -471,8 +501,7 @@ export default defineConfig({ rules: { "shadcn/no-raw-colors": "error" }, }, { - // Third-party marks (brand logos, the macOS permission panes, Codex's Computer Use - // mark) must keep their exact colors, so the files that hold them are exempt. + // Third-party marks must retain their exact colors. files: ["apps/web/src/components/Icons.tsx", "apps/web/src/components/JetBrainsIcons.tsx"], rules: { "shadcn/no-raw-colors": "off" }, }, @@ -510,9 +539,7 @@ export default defineConfig({ allow: ["layout"], contracts: [ { - // CollapsibleTrigger is a bare button with no styled counterpart - // (a disclosure row is not a Button), so its className is the API. - // Every other trigger has one: style them with render={