diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index b2f5443e431a1..66be80cd12312 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -31,11 +31,21 @@ when inspecting or seeding SQLite. Stop the test server before fixture writes. From the checkout being tested on the selected device host, run: ```bash -node scripts/mobile-native-client.ts ensure +vp run mobile:install ``` -This reuses a matching native client or builds and installs one. Authorized -mobile verification includes that build step unless the user prohibits it. +This installs the dev client for the checkout's native fingerprint: from the +device, the local cache, or the shared cache in seconds, building once only +when no machine has that fingerprint yet (5-15 minutes, logged to the file it +names). The device id may be omitted when exactly one device is booted. It +prints one JSON line; on failure it names the next command. Authorized mobile +verification includes that step unless the user prohibits it. + +For a device you opened in an agent-device session elsewhere (a device pool, +cloud, or remote host), run `vp run mobile:install --session ` +from the directory you opened the session in. It installs the cached build into +that session (`--abi arm64-v8a` for an ARM Android device; `--metro ` also +opens it against Metro) and never builds. Start `vp run dev:client` from `apps/mobile`, or reuse a healthy Metro belonging to this checkout. Open its printed development-client URL with AgentDevice diff --git a/.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/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 f99123d841dad..e9053e8887094 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,8 +163,10 @@ jobs: node apps/web/node_modules/playwright/cli.js install-deps chromium fi + # GitHub-hosted runners render about five times slower than upstream's, so the file + # preview browser tests need the browser to themselves and a longer timeout. - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --maxWorkers=1 --testTimeout=60000 # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of diff --git a/.gitignore b/.gitignore index 26b8965ecab8b..b33f3469c5345 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ apps/mobile/.generated/ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ +artifacts/mobile-e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/.macroscope/ignore.md b/.macroscope/ignore.md index 0541e7407c0c9..66e5434c5ed13 100644 --- a/.macroscope/ignore.md +++ b/.macroscope/ignore.md @@ -255,3 +255,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/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts index cb2954120928e..3484d6696953b 100644 --- a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts +++ b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/NodeServe.ts @@ -48,7 +48,7 @@ export interface NodeServeHandler { export interface NodeServeEntryOptions { /** * JS expression resolving the on-disk client-assets directory - * (typically `fileURLToPath(new URL("../client/", import.meta.url))`). + * (typically `fileURLToPath(new URL("../shared/", import.meta.url))`). * Omit to skip static-file serving (Next.js handles its own assets). */ readonly clientDirExpression?: string | undefined; diff --git a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts index 66667e6fb44c3..01147c61d5302 100644 --- a/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts +++ b/.repos/alchemy-effect/packages/frontend-frameworks/src/core/test/NodeServe.test.ts @@ -28,14 +28,14 @@ describe("relativeClientDirExpression", () => { "/project/dist/server/serve-node.mjs", "/project/dist/client", ), - ).toBe(`fileURLToPath(new URL("../client/", import.meta.url))`); + ).toBe(`fileURLToPath(new URL("../shared/", import.meta.url))`); }); }); describe("makeNodeServeEntrySource", () => { it("listens on PORT, answers /health, serves static files, then the fetch handler", () => { const source = makeNodeServeEntrySource({ - clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`, + clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`, handler: { kind: "fetch", imports: `import { handler } from "./index.mjs";`, @@ -131,7 +131,7 @@ describe("makeNodeServeEntrySource", () => { it("does not map GET / onto public/index.html (SSR home stays on the handler)", () => { const source = makeNodeServeEntrySource({ - clientDirExpression: `fileURLToPath(new URL("../client/", import.meta.url))`, + clientDirExpression: `fileURLToPath(new URL("../shared/", import.meta.url))`, handler: { kind: "node", imports: `import { handler } from "./index.mjs";`, 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 b054101a0b9ec..e9d6503a48ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ The most common defect in this repo is a change that works on the path you teste An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: -- The first `vp run dev` in a worktree does this for you when its database is missing. `--no-seed` or `T3CODE_DEV_SEED=0` skips it. +- The first `vp run dev` in a worktree does this for you when its database is missing and the source is at most 500 MiB. Larger sources warn and dev starts with an empty database. `--no-seed` or `T3CODE_DEV_SEED=0` skips seeding. Manual migration still accepts larger sources and may use substantial disk space before pruning. - Run `vp run migrate-dev-db` with your dev server stopped to refresh. It rebuilds `/.t3/userdata/statev2.sqlite` from a read-only snapshot of `~/.t3/userdata/statev2.sqlite`, the developer's real data. It keeps recent projects and their stopped threads, and drops scheduled tasks, pending work, and auth sessions, so your dev server never runs the developer's agents. Raise `--projects` and `--threads-per-project` for more data. - Refresh `statev2.sqlite`, not `state.sqlite`. The server copies the V1 `state.sqlite` only when `statev2.sqlite` is missing. - Bring `secrets` and `settings.json` only if the flow under test needs them. @@ -110,6 +110,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. - No per-test timeout argument or `retry`. The project timeout is the ceiling, and flaky tests are fixed, not retried. + This fork's CI `Test Web` job raises that ceiling to 60 seconds for GitHub-hosted runners ([ADR 0011](docs/adr/0011-fork-release-source.md)); tests must still pass within the project timeout locally. - No `performance.now()` or `Date.now()` deltas. Assert on events and counts, not elapsed time. - Browser tests assert through `expect.element` or `vi.waitFor`, never `requestAnimationFrame` or `transitionend`. - `vi.mock` factories must not spread a real module that pulls in Shiki or the diff workers. @@ -134,12 +135,12 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. -For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. +For authorized mobile verification, a missing or outdated native client is an install step, not a blocker. Run `vp run mobile:install [device-id]` on the device host before starting Metro; it targets the only booted simulator or connected Android device when the id is omitted. It installs the dev client for this checkout's native fingerprint from the shared cache in seconds and builds only when no machine has built it yet, so prefer it over `expo run:*`, `xcodebuild`, or `gradlew`, which rebuild from scratch. Its stdout is one JSON line; build output goes to the log it names. See `test-t3-mobile` for the full workflow. ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. -- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): use `cafe` for existing PRs, issues, comments and labels, but push and open PRs on GitHub, where CI runs. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): push to `origin`, open and track PRs with `cafe` and their git.cafe URLs, and keep `gh` for merging. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. - UI changes need before/after images. Motion or timing needs a short video. @@ -155,7 +156,7 @@ Beyond the required ADR, most code changes do not need an internal documentation - `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. @@ -195,7 +196,8 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ Named `Effect.fn` creates a span; the unnamed form does not. Preserve existing span names and options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. -- Comments say why: a constraint, an external quirk, or an invariant the code can't show. Don't restate names or signatures, narrate steps, or record history. `t3code/no-low-value-comments` catches the mechanical cases. +- Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. +- React effects are for synchronizing with something outside React. Derive in render and act in handlers instead of syncing state, but don't hide real lifecycle work in refs or stores to dodge one. The `t3code/react-effect-budget` lint rule holds each file to its count in `scripts/effect-budget.json`; a new effect states its reason with `// oxlint-disable-next-line t3code/react-effect-budget -- `. - 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 7dffe9fe3b1f8..f57e709bca7af 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -339,11 +339,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 688bd789572f7..340cb23136441 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -186,10 +186,7 @@ export async function startDesktopAppControlServer(input: { server.listen(address); }); - // Closing a Unix socket server unlinks the path it was bound to, even when - // another app's socket lives there now. Bind a staging path and move it onto - // the address instead, so a later close only unlinks the staging path, which - // is already gone. + // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first. const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); @@ -213,7 +210,6 @@ export async function startDesktopAppControlServer(input: { let server: NodeNet.Server; let inode: number | null = null; if (input.directory === null) { - // Named pipes close with the app that owns them, so no other app can remove this one. server = await listen(input.address); } else { await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); @@ -222,10 +218,10 @@ export async function startDesktopAppControlServer(input: { let closed = false; const reclaimOnce = async () => { - // Never replace a socket that exists, so two apps cannot trade the path back and forth. + // Replacing an existing socket would let two apps trade the path indefinitely. if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { - // A watch follows the directory's inode, so a recreated directory needs a new one. + // Directory watches follow inodes, so recreate the watch after directory replacement. watchDirectory(input.directory); } const next = await bindUnix(input.directory, "claim-free").catch( @@ -269,7 +265,7 @@ export async function startDesktopAppControlServer(input: { close: async () => { if (closed) return; closed = true; - // A running reclaim can replace the watcher, so close the watcher after it. + // Reclaim can replace the watcher; close it afterward. await pendingReclaim; watcher?.close(); for (const socket of sockets) socket.destroy(); @@ -386,7 +382,10 @@ export const make = Effect.gen(function* () { webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); }); }), - complete: (response) => Effect.sync(() => broker.complete(response)), + // The IPC handler owns the activation-completion span. + complete: Effect.fnUntraced(function* (response) { + return yield* Effect.sync(() => broker.complete(response)); + }), }); }); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 395c06141d64c..5fd6983c66682 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -38,7 +38,7 @@ const normalizeCommitHash = (value: string): Option.Option => { : Option.none(); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const assets = yield* DesktopAssets.DesktopAssets; const electronApp = yield* ElectronApp.ElectronApp; @@ -91,9 +91,7 @@ export const make = Effect.gen(function* () { const configure = Effect.gen(function* () { const commitHash = yield* resolveAboutCommitHash; - // Electron removes spaces from this name to build the native User-Agent - // product token, but leaves parentheses intact. Keep the runtime name valid - // without rewriting preview sessions (which breaks Turnstile, #7110). + // Electron strips spaces but keeps parentheses in its User-Agent token, breaking Turnstile in preview sessions. yield* electronApp.setName( `${environment.branding.baseName} ${environment.branding.stageLabel}`, ); @@ -107,9 +105,7 @@ export const make = Effect.gen(function* () { yield* electronApp.setAppUserModelId(environment.appUserModelId); } - // Unpackaged runs only. A packaged bundle already carries its icon in - // Info.plist, so setting the dock tile again changes nothing except to - // overwrite a custom icon the user attached to the app themselves. + // Packaged bundles carry their icon in Info.plist; setting it again overwrites the user's custom icon. if (environment.platform === "darwin" && !environment.isPackaged) { const iconPaths = yield* assets.iconPaths; yield* Option.match(iconPaths.png, { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 1e572412d4693..a244ff4b3fa7a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -28,8 +28,6 @@ vi.mock("@clerk/electron", () => ({ vi.mock("@clerk/electron/storage", () => ({ storage: storageMock, })); - -import * as Option from "effect/Option"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -297,7 +295,7 @@ it.effect( }), } as unknown as ElectronApp.ElectronApp["Service"]; const electronWindow = { - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: () => Effect.sync(() => revealed.resolve()), } as unknown as ElectronWindow.ElectronWindow["Service"]; return Effect.gen(function* () { 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..3cdfdb6cfa383 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -83,7 +83,6 @@ const fakePowerShell = (initial: string) => { return { registry, spawner }; }; -// The machine's own PATH may already hold a t3; each test sets the PATH it means. const machinePath = process.env.PATH; beforeEach(() => { process.env.PATH = ""; @@ -101,9 +100,15 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const command = yield* commandIn({ home }); const link = path.join(home, ".local", "bin", "t3"); - expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + expect(yield* command.state).toEqual({ + supported: true, + installedPath: null, + onPath: false, + launcherPath: null, + }); const installed = yield* command.install; expect(installed.installedPath).toBe(link); + expect(installed.launcherPath).toBe(path.join(home, ".t3", "bin", "t3")); expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); expect((yield* command.install).installedPath).toBe(link); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts index 294f8a8b984e7..2ae070d638263 100644 --- a/apps/desktop/src/app/DesktopCliCommand.ts +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -28,14 +28,8 @@ const pathEntries = (value: string | undefined, separator: string) => const sameWindowsPath = (left: string, right: string) => left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); -/** - * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping - * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for - * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` - * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it - * opens see the change. A failed read exits nonzero rather than reading as - * empty, so a write never replaces the user's PATH wholesale. - */ +// Preserve REG_EXPAND_SZ and %VAR% expansion; notify Explorer with WM_SETTINGCHANGE. +// Failed reads must exit nonzero to prevent replacing PATH wholesale. const WINDOWS_USER_PATH_SCRIPT = ` $ErrorActionPreference = 'Stop' if ($env:T3_SET -eq '1') { @@ -64,7 +58,7 @@ export class DesktopCliCommand extends Context.Service< } >()("@t3tools/desktop/app/DesktopCliCommand") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; @@ -156,18 +150,26 @@ export const make = Effect.gen(function* () { const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { if (!environment.isPackaged) { - return { supported: false, installedPath: null, onPath: false } as const; + return { supported: false, installedPath: null, onPath: false, launcherPath: null } as const; } + const launcherPath = (yield* exists(launcher)) ? launcher : null; const installed = yield* installedAt; const shadowedBy = yield* foreignFirstOnPath; const shadow = Option.isSome(shadowedBy) ? { shadowedBy: shadowedBy.value } : {}; if (Option.isNone(installed)) { - return { supported: true, installedPath: null, onPath: false, ...shadow }; + return { supported: true, installedPath: null, onPath: false, launcherPath, ...shadow }; } const first = yield* firstOnPath; const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); - return { supported: true, installedPath: installed.value, onPath, ...shadow }; - }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + return { supported: true, installedPath: installed.value, onPath, launcherPath, ...shadow }; + }).pipe( + Effect.orElseSucceed(() => ({ + supported: false, + installedPath: null, + onPath: false, + launcherPath: null, + })), + ); const ensureLauncher = DesktopCliShim.install.pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index e043b4d16d8de..50fcb08d9c64a 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -81,7 +81,6 @@ describe("renderCliShim", () => { executable: NodePath.join(root, "app"), entry: "/bin.mjs", }); - // sudo clears the environment, so the launcher supplies its own. expect(run(shim, []).stdout).toContain(`home=${root}/home`); expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index a9d9f04c4e829..3d4b94abff462 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -6,8 +6,7 @@ import * as Option from "effect/Option"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; -// An AppImage mounts somewhere new each run, so its launcher mounts the -// AppImage itself instead of pointing into it. +// AppImage mounts change each run; the launcher must mount the image itself. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; @@ -26,12 +25,7 @@ export type CliShimTarget = | { readonly kind: "direct"; readonly executable: string; readonly entry: string } | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; -/** - * Electron runs the server as plain Node with `ELECTRON_RUN_AS_NODE`, which - * reads the entry from inside the asar archive. The launcher's own path and the - * app's T3 home are written in, so the command the server shows is absolute and - * `sudo`, which clears the environment, still runs against this install's home. - */ +/** Embed this install's home and executable path so `sudo`, which clears the environment, uses the same install. */ export const renderCliShim = (input: { readonly target: CliShimTarget; readonly shimPath: string; @@ -48,9 +42,7 @@ export const renderCliShim = (input: { `rem ${MARKER}`, // Delayed expansion would rewrite `!name!` in paths and arguments. "setlocal EnableExtensions DisableDelayedExpansion", - // Only a non-ASCII path needs the console switched to UTF-8 to read it; a - // Ctrl-C that ends the batch skips the restore, so the switch is left out - // for the common all-ASCII install. + // Ctrl-C skips codepage restoration, so switch to UTF-8 only for non-ASCII install paths. ...(utf8 ? [ `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, @@ -97,8 +89,7 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The AppImage runtime prints its mount point, then stays mounted until - // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. "work=$(mktemp -d) || exit 1", '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", diff --git a/apps/desktop/src/app/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.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 6ddb31cb6eaac..ab8ea0b8e9d6e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -342,4 +342,36 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), ); }); + + it.effect("describes the Node runtime and the desktop mode under semantic or t3code keys", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const layerEnvironment = layerEnvironmentFor(baseDir); + yield* writeObservabilitySettings(layerEnvironment, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop resource under test").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(layerEnvironment))), + ), + ); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"process.runtime.name","value":{"stringValue":"nodejs"}'); + assert.include( + body, + `"key":"process.runtime.version","value":{"stringValue":"${process.versions.node}"}`, + ); + assert.include(body, '"key":"t3code.desktop.mode","value":{"stringValue":"development"}'); + assert.notInclude(body, "service.mode"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, layerCollector(requests), layerEmptyEnv)), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 55d200a159177..3cd3936a59bfc 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,14 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + nodeProcessRuntimeAttributes, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -428,8 +431,7 @@ const backendLogFilePathForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, ): string => { - // Primary keeps the historical "server-child.log" path so ops scripts - // and packaged-build log inspection still find it where it always lived. + // Keep the primary log path stable for ops scripts and packaged-build inspection. if (id === PRIMARY_BACKEND_LOG_INSTANCE_ID) { return environment.path.join(environment.logDir, "server-child.log"); } @@ -437,8 +439,7 @@ const backendLogFilePathForInstance = ( return environment.path.join(environment.logDir, `server-child-${sanitized}.log`); }; -// Cacheable by resolved file path so two ids that sanitize to the same filename -// share a single RotatingLogFileWriter (no race on currentSize tracking). +// IDs sharing a sanitized filename must share a writer to avoid currentSize races. const makeBackendOutputSinkForInstance = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], id: string, @@ -458,90 +459,91 @@ const makeBackendOutputLogShape = ( ): Effect.Effect => Option.match(sink, { onNone: () => Effect.succeed(DesktopBackendOutputLogNoop), - onSome: (logFile) => - Effect.gen(function* () { - const sessionRef = yield* Ref.make(Option.none()); - const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")( - function* (session: BackendOutputSession, details: string) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output start", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "START", - details: session.startDetails, - }, - }); - for (const output of session.chunks) { - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process output", - level: output.streamName === "stderr" ? "ERROR" : "INFO", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - stream: output.streamName, - text: textDecoder.decode(output.chunk.subarray(output.offset)), - }, - }); - } - yield* writeBackendChildLogRecord(logFile, { - message: "backend child process failure output end", - level: "ERROR", - annotations: { - component: "desktop-backend-child", - runId: session.runId, - instanceId: id, - phase: "END", - details: sanitizeLogValue(details), - }, - }); + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (logFile) { + const sessionRef = yield* Ref.make(Option.none()); + const writeFailure = Effect.fn("desktop.observability.backendOutput.writeFailure")(function* ( + session: BackendOutputSession, + details: string, + ) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output start", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "START", + details: session.startDetails, }, - ); - return { - beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ - details, - }) { - const runId = yield* currentDesktopRunId; - yield* Ref.set( - sessionRef, - Option.some({ - runId, - startDetails: sanitizeLogValue(details), - chunks: [], - byteLength: 0, - }), - ); - }), - writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { - if (environment.isDevelopment) { - yield* writeDevelopmentConsoleOutput(streamName, chunk); - } - yield* Ref.update( - sessionRef, - Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), - ); - }), - persistFailureSnapshot: Effect.fn( - "desktop.observability.backendOutput.persistFailureSnapshot", - )(function* ({ details }) { - const session = yield* Ref.get(sessionRef); - if (Option.isSome(session)) { - yield* writeFailure(session.value, details); - } - }), - persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")( - function* ({ details }) { - const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); - if (Option.isNone(session)) return; - yield* writeFailure(session.value, details); + }); + for (const output of session.chunks) { + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process output", + level: output.streamName === "stderr" ? "ERROR" : "INFO", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + stream: output.streamName, + text: textDecoder.decode(output.chunk.subarray(output.offset)), }, - ), - discardSession: Ref.set(sessionRef, Option.none()), - } satisfies DesktopBackendOutputLogShape; - }), + }); + } + yield* writeBackendChildLogRecord(logFile, { + message: "backend child process failure output end", + level: "ERROR", + annotations: { + component: "desktop-backend-child", + runId: session.runId, + instanceId: id, + phase: "END", + details: sanitizeLogValue(details), + }, + }); + }); + return { + beginSession: Effect.fn("desktop.observability.backendOutput.beginSession")(function* ({ + details, + }) { + const runId = yield* currentDesktopRunId; + yield* Ref.set( + sessionRef, + Option.some({ + runId, + startDetails: sanitizeLogValue(details), + chunks: [], + byteLength: 0, + }), + ); + }), + writeOutputChunk: Effect.fnUntraced(function* (streamName, chunk) { + if (environment.isDevelopment) { + yield* writeDevelopmentConsoleOutput(streamName, chunk); + } + yield* Ref.update( + sessionRef, + Option.map((session) => appendBoundedOutputChunk(session, streamName, chunk)), + ); + }), + persistFailureSnapshot: Effect.fn( + "desktop.observability.backendOutput.persistFailureSnapshot", + )(function* ({ details }) { + const session = yield* Ref.get(sessionRef); + if (Option.isSome(session)) { + yield* writeFailure(session.value, details); + } + }), + persistFailure: Effect.fn("desktop.observability.backendOutput.persistFailure")(function* ({ + details, + }) { + const session = yield* Ref.modify(sessionRef, (current) => [current, Option.none()]); + if (Option.isNone(session)) return; + yield* writeFailure(session.value, details); + }), + discardSession: Ref.set(sessionRef, Option.none()), + } satisfies DesktopBackendOutputLogShape; + }), }); const layerBackendOutputLogFactory = Layer.effect( @@ -588,7 +590,9 @@ const layerBackendOutputLogFactory = Layer.effect( }); return DesktopBackendOutputLogFactory.of({ - forInstance: (id) => makeForId(id), + forInstance: Effect.fn("DesktopBackendOutputLogFactory.forInstance")(function (id) { + return makeForId(id); + }), }); }), ); @@ -597,28 +601,28 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", - "service.mode": environment.isDevelopment ? "development" : "packaged", + "t3code.desktop.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), + ...nodeProcessRuntimeAttributes(), }, }; - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the main process wants has to be named in this one call. - // Splitting the OTLP logger back out into a layer of its own silently - // drops either it or the console output. - // Swapping `Logger.tracerLogger` out for the OTLP logger matches the - // server: both reach a collector, but the tracer logger covers only - // messages logged inside a recorded span and files them under traces, - // while the OTLP logger carries every message as a log record stamped - // with its trace and span ids. Keeping both would export every in-span - // message twice. + // Logger.layer replaces the logger set. tracerLogger would duplicate OTLP in-span messages. const layerLogger = Logger.layer( endpoints.logs === undefined ? [Logger.consolePretty(), Logger.tracerLogger] diff --git a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts index 5af13f94413f5..6a7473e2f1b1a 100644 --- a/apps/desktop/src/app/DesktopPreReadyFileSystem.ts +++ b/apps/desktop/src/app/DesktopPreReadyFileSystem.ts @@ -32,9 +32,7 @@ const syncFs = (method: string, path: string, run: () => A) => }), }); -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = FileSystem.makeNoop({ exists: (path) => syncFs("exists", path, () => { diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.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/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9e87cfb7a9c02..9b5bc89148dea 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -26,7 +25,7 @@ const makeWindow = () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), reveal: (target: Electron.BrowserWindow) => Effect.sync(() => void revealed.push(target)), } as unknown as ElectronWindow.ElectronWindow["Service"]), ), diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index f448f184b893f..ce63a710f7f7e 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -69,16 +69,15 @@ const make = Effect.gen(function* () { }); return DesktopWebLinks.of({ - receive: (url) => - Effect.suspend(() => { + receive: Effect.fn("DesktopWebLinks.receive")(function (url) { + return Effect.suspend(() => { pending.push(url); return flush; - }).pipe(Effect.withSpan("DesktopWebLinks.receive")), - setRendererReady: (ready) => - (ready ? startListening : Effect.sync(stopListening)).pipe( - Effect.andThen(flush), - Effect.withSpan("DesktopWebLinks.setRendererReady"), - ), + }); + }), + setRendererReady: Effect.fn("DesktopWebLinks.setRendererReady")(function (ready) { + return ready ? startListening : Effect.sync(stopListening); + }, Effect.andThen(flush)), }); }); diff --git a/apps/desktop/src/app/chromiumLocalStorage.ts b/apps/desktop/src/app/chromiumLocalStorage.ts index 98de2e91eb063..51beceb9e463f 100644 --- a/apps/desktop/src/app/chromiumLocalStorage.ts +++ b/apps/desktop/src/app/chromiumLocalStorage.ts @@ -4,12 +4,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -/** - * It reads the database files directly and never takes LevelDB's lock, so it - * works while another app version has the profile open. Live files come from - * the MANIFEST that CURRENT names, and the newest sequence number wins, which - * is how LevelDB itself resolves overwrites and deletions. - */ +/** Read without LevelDB's lock so another app can keep the profile open. */ const LOG_BLOCK_SIZE = 32 * 1024; const LOG_HEADER_SIZE = 7; @@ -145,11 +140,7 @@ function decompressSnappy(input: Uint8Array): Uint8Array { return output; } -/** - * Like LevelDB's own reader, a corrupt record or zero padding skips the rest of - * its block, since a reused log keeps appending after a torn write, and a - * record cut off by the end of the file ends the log. - */ +/** Corruption skips the remaining block; a truncated final record ends the log, matching LevelDB recovery. */ function readLogRecords(bytes: Uint8Array): Uint8Array[] { const records: Uint8Array[] = []; let pending: Uint8Array[] | null = null; @@ -422,9 +413,7 @@ export const readChromiumLocalStorage = Effect.fn("desktop.chromiumLocalStorage. }).pipe(wrap); } - // A flush while another process has the database open can move entries - // from a log we read into a table our MANIFEST copy predates. Fail so the - // caller retries rather than import a snapshot missing those entries. + // Concurrent flushes can move entries beyond this MANIFEST snapshot; detect changes so the caller retries. const currentAfter = textDecoderUtf8.decode(yield* readFile("CURRENT").pipe(wrap)).trim(); const manifestAfter = yield* fs.stat(path.join(directory, current)).pipe(wrap); if (currentAfter !== current || Number(manifestAfter.size) !== manifest.length) { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 6ec028c0d7c6c..e5bf06a9a626d 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -727,7 +727,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -737,10 +737,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -750,6 +752,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -771,6 +775,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index eadd741e13306..aa6cd0d12d980 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", @@ -113,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -145,10 +143,7 @@ const mergeWslEnv = ( const additions = forwardedEnvNames.filter((name) => !seenNames.has(name)); - // Preserve the user's WSLENV exactly as Windows handed it to us — empty - // "::" segments and duplicate entries are harmless no-ops to WSL and not - // ours to normalize — and only append the secrets we need to forward - // across the wsl.exe boundary. + // Preserve empty segments and duplicates in the user's WSLENV; only append required variables. const parts = [existing, ...additions].filter((part) => part.length > 0); return parts.length > 0 ? parts.join(":") : undefined; }; @@ -253,10 +248,7 @@ type WslPreflightRuntime = } | { readonly kind: "node-script"; - // Absolute path to the node binary the preflight validated after the - // shared remote resolver repaired PATH. The launch must use this exact - // path so it doesn't fall through to a different/old node than the one - // node-pty was probed with. + // Launch the exact Node path probed with node-pty, even if later PATH lookup would resolve another version. readonly nodePath: string; readonly linuxEntryPath: string; }; @@ -397,8 +389,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f if (input.runtimeArchive !== null) { const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive); if (runtime.ok) { - // The staged runtime supplies its own Node and node-pty. Provider PATH - // discovery must not require either dependency for runtime readiness. + // Staged runtimes bundle Node and node-pty; provider PATH discovery must not require them. const stagedProbe = yield* wslEnv.probeRuntime(runningDistro, runtime.linuxAppRoot); if (stagedProbe.ok) { yield* wslServerTree.cleanupLegacy; @@ -438,10 +429,7 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f nodePtyOptions, ); if (!nodePtyResult.ok) { - // Substituting the staged verdict for a transient mounted failure would - // turn a retryable failure into a fatal one, ending the WSL attempt (and, - // in wsl-only mode, persisting Windows) before the slow /mnt path had a - // chance to answer and clear the bad cache. + // Only substitute a staged failure for a fatal mounted failure; transient /mnt failures must remain retryable. return stagedFailure && nodePtyResult.fatal ? failedStaged(stagedFailure) : failedNodePty(nodePtyResult); @@ -464,16 +452,12 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f } as const; }); -// In WSL2 mirrored mode the distro's eth0 IP equals the host's, which is the -// signature we use to detect that mode and switch the renderer URL to loopback. +// In WSL2 mirrored mode, eth0 matches a host IP; use loopback for the renderer. const isLocalHostIpv4 = (ip: string): boolean => { const interfaces = NodeOS.networkInterfaces(); for (const list of Object.values(interfaces)) { if (!list) continue; for (const entry of list) { - // os.networkInterfaces() reports IPv4 `family` as the string "IPv4" on - // the Node build Electron ships (41 / Node 22, verified), but some Node - // builds report the numeric 4. const family = String(entry.family); if ((family === "IPv4" || family === "4") && entry.address === ip) return true; } @@ -520,7 +504,6 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, desktopBootstrapSecret: input.bootstrapSecret, - // Failed Windows probes leave server hydration enabled for another attempt. shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, @@ -537,10 +520,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - // Packaged builds only, so a dev instance never shares the cache with the - // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, - // so the setting does not leak into the provider and terminal processes - // the backend starts. + // Packaged builds have a separate cache; --require prevents cache settings leaking to provider subprocesses. args: [ ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), environment.backendEntryPath, @@ -582,25 +562,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), @@ -641,9 +614,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; @@ -688,8 +659,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, @@ -698,8 +668,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], @@ -722,10 +691,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" @@ -754,7 +720,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; @@ -764,12 +730,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, { @@ -841,18 +802,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..8b5e8045ed371 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, @@ -1059,58 +1043,58 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); yield* Option.match(active, { onNone: () => Effect.void, - onSome: (run) => - Effect.gen(function* () { - const closed = yield* closeRun(run, parentScope, options); - if (!closed) { - return; - } - const cleanup = yield* mutex.withPermits(1)( - Ref.modify( - state, - ( - latest, - ): readonly [ - { - readonly needsCleanup: boolean; - readonly shouldStart: boolean; - }, - BackendManagerState, - ] => { - const current = Option.getOrUndefined(latest.active); - if (current?.id !== run.id) { - return [ - { - needsCleanup: false, - shouldStart: - latest.desiredRunning && - Option.isNone(latest.active) && - Option.isNone(latest.restartFiber), - }, - latest, - ]; - } + // The enclosing operation owns the span for this inline handler. + onSome: Effect.fnUntraced(function* (run) { + const closed = yield* closeRun(run, parentScope, options); + if (!closed) { + return; + } + const cleanup = yield* mutex.withPermits(1)( + Ref.modify( + state, + ( + latest, + ): readonly [ + { + readonly needsCleanup: boolean; + readonly shouldStart: boolean; + }, + BackendManagerState, + ] => { + const current = Option.getOrUndefined(latest.active); + if (current?.id !== run.id) { return [ { - needsCleanup: true, - shouldStart: latest.desiredRunning, - }, - { - ...latest, - active: Option.none(), + needsCleanup: false, + shouldStart: + latest.desiredRunning && + Option.isNone(latest.active) && + Option.isNone(latest.restartFiber), }, + latest, ]; - }, - ), - ); - if (cleanup.needsCleanup) { - yield* desktopTelemetryPublisher.removeControlSource(spec.id); - yield* backendOutputLog.discardSession; - } - if (cleanup.shouldStart) { - yield* start; - } - }), + } + return [ + { + needsCleanup: true, + shouldStart: latest.desiredRunning, + }, + { + ...latest, + active: Option.none(), + }, + ]; + }, + ), + ); + if (cleanup.needsCleanup) { + yield* desktopTelemetryPublisher.removeControlSource(spec.id); + yield* backendOutputLog.discardSession; + } + if (cleanup.shouldStart) { + yield* start; + } + }), }); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index a8576a2b9e99e..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..4292b92a3d0e4 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), @@ -304,13 +291,14 @@ export const layer = Layer.effect( }); return DesktopBackendPool.of({ - get: (id) => - SynchronizedRef.get(instancesRef).pipe( + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => { const entry = instances.get(id); return entry?._tag === "Active" ? Option.some(entry.instance) : Option.none(); }), - ), + ); + }), list: SynchronizedRef.get(instancesRef).pipe( Effect.map((instances) => Array.from(instances.values()).flatMap((entry) => @@ -339,11 +327,17 @@ export const layerTest = ( ); const primary = instances[0]!; return DesktopBackendPool.of({ - get: (id) => Effect.succeed(Option.fromNullishOr(byId.get(id))), + get: Effect.fn("DesktopBackendPool.get")(function (id) { + return Effect.succeed(Option.fromNullishOr(byId.get(id))); + }), list: Effect.succeed(Array.from(byId.values())), primary: Effect.succeed(primary), - register: () => Effect.die("DesktopBackendPool.layerTest does not support register"), - unregister: () => Effect.die("DesktopBackendPool.layerTest does not support unregister"), + register: Effect.fn("DesktopBackendPool.register")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support register"); + }), + unregister: Effect.fn("DesktopBackendPool.unregister")(function () { + return Effect.die("DesktopBackendPool.layerTest does not support unregister"); + }), }); }), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 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..04f26b1bced84 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,12 +43,7 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; - /** - * The OS locale, read from the operating system rather than from Chromium's - * resolved application locale — the packaged app ships only the `en-US` - * locale pak, so `app.getLocale()` and the renderer's `Intl` default are - * pinned to `en-US` however the machine is configured. - */ + /** Packaged builds ship only en-US; app.getLocale() and Intl defaults do not reflect the OS locale. */ readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; @@ -98,7 +93,7 @@ const addScopedAppListener = >( }), ).pipe(Effect.asVoid); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronApp.of({ metadata: Effect.gen(function* () { const appVersion = yield* Effect.try({ @@ -127,9 +122,7 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), - // macOS derives this from NSLocale, which uses POSIX-style identifiers - // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so - // the tag is normalized here rather than in the renderer that consumes it. + // NSLocale returns en_GB-style tags; Intl requires hyphens. systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; @@ -142,59 +135,73 @@ export const make = ElectronApp.of({ quit: Effect.sync(() => { Electron.app.quit(); }), - exit: (code) => - Effect.sync(() => { + exit: Effect.fn("ElectronApp.exit")(function (code) { + return Effect.sync(() => { Electron.app.exit(code); - }), - relaunch: (options) => - Effect.sync(() => { + }); + }), + relaunch: Effect.fn("ElectronApp.relaunch")(function (options) { + return Effect.sync(() => { Electron.app.relaunch(options); - }), - setPath: (name, path) => - Effect.sync(() => { + }); + }), + setPath: Effect.fn("ElectronApp.setPath")(function (name, path) { + return Effect.sync(() => { Electron.app.setPath(name, path); - }), - setName: (name) => - Effect.sync(() => { + }); + }), + setName: Effect.fn("ElectronApp.setName")(function (name) { + return Effect.sync(() => { Electron.app.setName(name); - }), - setAboutPanelOptions: (options) => - Effect.sync(() => { + }); + }), + setAboutPanelOptions: Effect.fn("ElectronApp.setAboutPanelOptions")(function (options) { + return Effect.sync(() => { Electron.app.setAboutPanelOptions(options); - }), - setAppUserModelId: (id) => - Effect.sync(() => { + }); + }), + setAppUserModelId: Effect.fn("ElectronApp.setAppUserModelId")(function (id) { + return Effect.sync(() => { Electron.app.setAppUserModelId(id); - }), + }); + }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - setAsDefaultProtocolClient: (protocol, path, args) => - Effect.sync(() => { - if (path === undefined) { - return Electron.app.setAsDefaultProtocolClient(protocol); - } - return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); - }), - setDesktopName: (desktopName) => - Effect.sync(() => { + setAsDefaultProtocolClient: Effect.fn("ElectronApp.setAsDefaultProtocolClient")( + function (protocol, path, args) { + return Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }); + }, + ), + setDesktopName: Effect.fn("ElectronApp.setDesktopName")(function (desktopName) { + return Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { setDesktopName?: (desktopName: string) => void; }; linuxApp.setDesktopName?.(desktopName); - }), - setDockIcon: (iconPath) => - Effect.sync(() => { + }); + }), + setDockIcon: Effect.fn("ElectronApp.setDockIcon")(function (iconPath) { + return Effect.sync(() => { Electron.app.dock?.setIcon(iconPath); - }), - appendCommandLineSwitch: (switchName, value) => - Effect.sync(() => { - if (value === undefined) { - Electron.app.commandLine.appendSwitch(switchName); - return; - } - Electron.app.commandLine.appendSwitch(switchName, value); - }), - onBeforeQuitForUpdate: (listener) => - Effect.acquireRelease( + }); + }), + appendCommandLineSwitch: Effect.fn("ElectronApp.appendCommandLineSwitch")( + function (switchName, value) { + return Effect.sync(() => { + if (value === undefined) { + Electron.app.commandLine.appendSwitch(switchName); + return; + } + Electron.app.commandLine.appendSwitch(switchName, value); + }); + }, + ), + onBeforeQuitForUpdate: Effect.fn("ElectronApp.onBeforeQuitForUpdate")(function (listener) { + return Effect.acquireRelease( Effect.sync(() => { Electron.autoUpdater.on("before-quit-for-update", listener); }), @@ -202,11 +209,13 @@ export const make = ElectronApp.of({ Effect.sync(() => { Electron.autoUpdater.removeListener("before-quit-for-update", listener); }), - ).pipe(Effect.asVoid), - removeCommandLineSwitch: (switchName) => - Effect.sync(() => { + ).pipe(Effect.asVoid); + }), + removeCommandLineSwitch: Effect.fn("ElectronApp.removeCommandLineSwitch")(function (switchName) { + return Effect.sync(() => { Electron.app.commandLine.removeSwitch(switchName); - }), + }); + }), on: addScopedAppListener, }); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index b48953f117d38..4f9ab12eddc39 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -104,7 +104,7 @@ export class ElectronDialog extends Context.Service< } >()("@t3tools/desktop/electron/ElectronDialog") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronDialog.of({ pickFolder: Effect.fn("desktop.electron.dialog.pickFolder")(function* (input) { const ownerWindowId = Option.match(input.owner, { @@ -166,8 +166,8 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), - showMessageBox: (options, owner) => - Effect.tryPromise({ + showMessageBox: Effect.fn("ElectronDialog.showMessageBox")(function (options, owner) { + return Effect.tryPromise({ try: () => owner === undefined ? Electron.dialog.showMessageBox(options) @@ -181,9 +181,10 @@ export const make = ElectronDialog.of({ buttonCount: options.buttons?.length ?? 0, cause, }), - }), - showErrorBox: (title, content) => - Effect.try({ + }); + }), + showErrorBox: Effect.fn("ElectronDialog.showErrorBox")(function (title, content) { + return Effect.try({ try: () => Electron.dialog.showErrorBox(title, content), catch: (cause) => new ElectronDialogShowErrorBoxError({ @@ -191,7 +192,8 @@ export const make = ElectronDialog.of({ contentLength: content.length, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), }); export const layer = Layer.succeed(ElectronDialog, make); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 352a5bc97a21a..d4338b45e280b 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -68,8 +68,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } - // Header items are decorative section labels for the web fallback only — - // Electron's native menu has no equivalent affordance, so we skip them. + // Native menus cannot display the web fallback's decorative headers. if (sourceItem.header === true) { continue; } @@ -97,9 +96,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } -// Renderer positions arrive in CSS pixels; popup() expects window points, so -// page zoom must be factored in or menus drift proportionally to their -// distance from the window origin. +// popup() takes window points; renderer CSS coordinates must account for page zoom. const normalizePosition = ( position: Option.Option, zoomFactor: number, @@ -112,7 +109,7 @@ const normalizePosition = ( Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; let destructiveMenuIconCache: Option.Option | undefined; @@ -190,8 +187,8 @@ export const make = Effect.gen(function* () { }; return ElectronMenu.of({ - setApplicationMenu: (template) => - Effect.try({ + setApplicationMenu: Effect.fn("ElectronMenu.setApplicationMenu")(function (template) { + return Effect.try({ try: () => { Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); }, @@ -203,9 +200,10 @@ export const make = Effect.gen(function* () { itemCount: template.length, cause, }), - }).pipe(Effect.orDie), - popupTemplate: (input) => - input.template.length === 0 + }).pipe(Effect.orDie); + }), + popupTemplate: Effect.fn("ElectronMenu.popupTemplate")(function (input) { + return input.template.length === 0 ? Effect.void : Effect.try({ try: () => @@ -221,9 +219,10 @@ export const make = Effect.gen(function* () { itemCount: input.template.length, cause, }), - }).pipe(Effect.orDie), - showContextMenu: (input) => - Effect.callback>((resume) => { + }).pipe(Effect.orDie); + }), + showContextMenu: Effect.fn("ElectronMenu.showContextMenu")(function (input) { + return Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { resume(Effect.succeedNone); @@ -275,7 +274,8 @@ export const make = Effect.gen(function* () { ), ); } - }), + }); + }), }); }); diff --git a/apps/desktop/src/electron/ElectronPowerMonitor.ts b/apps/desktop/src/electron/ElectronPowerMonitor.ts index 4fb42dfdc9f47..5a702b61a8111 100644 --- a/apps/desktop/src/electron/ElectronPowerMonitor.ts +++ b/apps/desktop/src/electron/ElectronPowerMonitor.ts @@ -75,12 +75,15 @@ const onSpeedLimitChange: ElectronPowerMonitor["Service"]["onSpeedLimitChange"] ).pipe(Effect.asVoid); }; -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronPowerMonitor.of({ isOnBatteryPower: Effect.sync(() => Electron.powerMonitor.isOnBatteryPower()), getSystemIdleTime: Effect.sync(() => Electron.powerMonitor.getSystemIdleTime()), - getSystemIdleState: (idleThresholdSeconds) => - Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)), + getSystemIdleState: Effect.fn("ElectronPowerMonitor.getSystemIdleState")( + function (idleThresholdSeconds) { + return Effect.sync(() => Electron.powerMonitor.getSystemIdleState(idleThresholdSeconds)); + }, + ), getCurrentThermalState: Effect.sync(() => Electron.powerMonitor.getCurrentThermalState()), onSimpleEvent, onThermalStateChange, diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index d6f476205f4c5..d84b6f360c89e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -78,11 +78,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "https://challenges.cloudflare.com", ]; - // The renderer connects directly to user-configured environments in addition to - // the build-configured Clerk, relay, and OTLP endpoints. Those environment - // origins are not known when this response policy is created, so restrict - // connections by the network schemes the client supports instead of by host. - // GLTFLoader fetches embedded textures through blob URLs after parsing the model. + // User-configured environment origins are unknown here, so allow supported network schemes. + // GLTFLoader fetches embedded textures through blob URLs. const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"]; return [ @@ -110,9 +107,7 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } -/** - * Must run synchronously during process bootstrap, before Electron emits `ready`. - */ +// Must run synchronously before Electron emits ready. function registerDesktopSchemePrivilegesSync(): void { Electron.protocol.registerSchemesAsPrivileged([ { @@ -123,8 +118,7 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, - // Custom schemes skip Chromium's V8 code cache unless they opt in. - // Dev stays off: Vite serves changing code at stable URLs. + // Custom schemes must opt into V8 caching; dev URLs serve changing code. codeCache: true, }, }, @@ -246,7 +240,7 @@ const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* ( }); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); const context = yield* Effect.context(); diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index e56ebf21ea66b..0486da8a2753c 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -69,7 +69,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; const selectedStorageBackend = Effect.sync(() => { @@ -86,16 +86,18 @@ export const make = Effect.gen(function* () { try: () => Electron.safeStorage.isEncryptionAvailable(), catch: (cause) => new ElectronSafeStorageAvailabilityError({ cause }), }), - encryptString: (value) => - Effect.try({ + encryptString: Effect.fn("ElectronSafeStorage.encryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.encryptString(value), catch: (cause) => new ElectronSafeStorageEncryptError({ cause }), - }), - decryptString: (value) => - Effect.try({ + }); + }), + decryptString: Effect.fn("ElectronSafeStorage.decryptString")(function (value) { + return Effect.try({ try: () => Electron.safeStorage.decryptString(Buffer.from(value)), catch: (cause) => new ElectronSafeStorageDecryptError({ cause }), - }), + }); + }), decryptLegacyString: Effect.fn("desktop.electron.safeStorage.decryptLegacyString")( function* (value, accept) { const backend = yield* selectedStorageBackend; diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 0216d7b2c7dd1..e32dba36a6dfa 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -11,9 +11,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, -// `zed://ssh//`, `jetbrains://gateway/ssh/environment?h=…`) must -// reach the OS handler; every other non-web scheme stays blocked. +// Remote editor deep links must reach the OS handler. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -63,10 +61,10 @@ export class ElectronShell extends Context.Service< } >()("@t3tools/desktop/electron/ElectronShell") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronShell.of({ - openExternal: (rawUrl) => - Option.match(parseSafeExternalUrl(rawUrl), { + openExternal: Effect.fn("ElectronShell.openExternal")(function (rawUrl) { + return Option.match(parseSafeExternalUrl(rawUrl), { onNone: () => Effect.succeed(false), onSome: (externalUrl) => Effect.promise(() => @@ -75,16 +73,19 @@ export const make = ElectronShell.of({ () => false, ), ), - }), - openSystemSettings: (pane) => - Effect.promise(() => + }); + }), + openSystemSettings: Effect.fn("ElectronShell.openSystemSettings")(function (pane) { + return Effect.promise(() => Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( () => true, () => false, ), - ), - copyText: (text) => - Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), + ); + }), + copyText: Effect.fn("ElectronShell.copyText")(function (text) { + return Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)); + }), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index 42cac98d0f538..ab0673b817ae2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -28,18 +28,19 @@ export class ElectronTheme extends Context.Service< } >()("@t3tools/desktop/electron/ElectronTheme") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronTheme.of({ shouldUseDarkColors: Effect.sync(() => Electron.nativeTheme.shouldUseDarkColors), - setSource: (theme) => - Effect.try({ + setSource: Effect.fn("ElectronTheme.setSource")(function (theme) { + return Effect.try({ try: () => { Electron.nativeTheme.themeSource = theme; }, catch: (cause) => new ElectronThemeSetSourceError({ source: theme, cause }), - }), - onUpdated: (listener) => - Effect.acquireRelease( + }); + }), + onUpdated: Effect.fn("ElectronTheme.onUpdated")(function (listener) { + return Effect.acquireRelease( Effect.suspend(() => { Electron.nativeTheme.on("updated", listener); return Effect.void; @@ -49,7 +50,8 @@ export const make = ElectronTheme.of({ Electron.nativeTheme.removeListener("updated", listener); return Effect.void; }), - ), + ); + }), }); export const layer = Layer.succeed(ElectronTheme, make); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index e42ebc1003ab4..c71b42040db29 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -80,49 +80,59 @@ export class ElectronUpdater extends Context.Service< } >()("@t3tools/desktop/electron/ElectronUpdater") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = ElectronUpdater.of({ - setFeedURL: (options) => - Effect.suspend(() => { + setFeedURL: Effect.fn("ElectronUpdater.setFeedURL")(function (options) { + return Effect.suspend(() => { autoUpdater.setFeedURL(options); return Effect.void; - }), - setAutoDownload: (value) => - Effect.suspend(() => { + }); + }), + setAutoDownload: Effect.fn("ElectronUpdater.setAutoDownload")(function (value) { + return Effect.suspend(() => { autoUpdater.autoDownload = value; return Effect.void; - }), - setAutoInstallOnAppQuit: (value) => - Effect.suspend(() => { + }); + }), + setAutoInstallOnAppQuit: Effect.fn("ElectronUpdater.setAutoInstallOnAppQuit")(function (value) { + return Effect.suspend(() => { autoUpdater.autoInstallOnAppQuit = value; return Effect.void; - }), - setChannel: (channel) => - Effect.suspend(() => { + }); + }), + setChannel: Effect.fn("ElectronUpdater.setChannel")(function (channel) { + return Effect.suspend(() => { autoUpdater.channel = channel; return Effect.void; - }), - setAllowPrerelease: (value) => - Effect.suspend(() => { + }); + }), + setAllowPrerelease: Effect.fn("ElectronUpdater.setAllowPrerelease")(function (value) { + return Effect.suspend(() => { autoUpdater.allowPrerelease = value; return Effect.void; - }), + }); + }), allowDowngrade: Effect.sync(() => autoUpdater.allowDowngrade), - setAllowDowngrade: (value) => - Effect.suspend(() => { + setAllowDowngrade: Effect.fn("ElectronUpdater.setAllowDowngrade")(function (value) { + return Effect.suspend(() => { autoUpdater.allowDowngrade = value; return Effect.void; - }), - setFullChangelog: (value) => - Effect.suspend(() => { + }); + }), + setFullChangelog: Effect.fn("ElectronUpdater.setFullChangelog")(function (value) { + return Effect.suspend(() => { autoUpdater.fullChangelog = value; return Effect.void; - }), - setDisableDifferentialDownload: (value) => - Effect.suspend(() => { - autoUpdater.disableDifferentialDownload = value; - return Effect.void; - }), + }); + }), + setDisableDifferentialDownload: Effect.fn("ElectronUpdater.setDisableDifferentialDownload")( + function (value) { + return Effect.suspend(() => { + autoUpdater.disableDifferentialDownload = value; + return Effect.void; + }); + }, + ), checkForUpdates: Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.tryPromise({ @@ -137,8 +147,11 @@ export const make = ElectronUpdater.of({ catch: (cause) => new ElectronUpdaterDownloadUpdateError({ channel, cause }), }).pipe(Effect.asVoid); }), - quitAndInstall: ({ isSilent, isForceRunAfter }) => - Effect.suspend(() => { + quitAndInstall: Effect.fn("ElectronUpdater.quitAndInstall")(function ({ + isSilent, + isForceRunAfter, + }) { + return Effect.suspend(() => { const channel = autoUpdater.channel; return Effect.try({ try: () => autoUpdater.quitAndInstall(isSilent, isForceRunAfter), @@ -150,8 +163,9 @@ export const make = ElectronUpdater.of({ cause, }), }); - }), - on: (eventName, listener) => { + }); + }), + on: Effect.fn("ElectronUpdater.on")(function (eventName, listener) { const eventTarget = autoUpdater as unknown as { on: (eventName: string, listener: (...args: Array) => void) => void; removeListener: (eventName: string, listener: (...args: Array) => void) => void; @@ -166,7 +180,7 @@ export const make = ElectronUpdater.of({ eventTarget.removeListener(eventName, untypedListener); }), ).pipe(Effect.asVoid); - }, + }), }); export const layer = Layer.succeed(ElectronUpdater, make); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 993e930678ed7..35303ed935aef 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -127,11 +127,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( @@ -208,7 +207,7 @@ export const make = Effect.gen(function* () { }); return ElectronWindow.of({ - create: (options) => { + create: Effect.fn("ElectronWindow.create")(function (options) { const webPreferences = options.webPreferences; const diagnosticOptions = { title: options.title ?? null, @@ -236,13 +235,15 @@ export const make = Effect.gen(function* () { try: () => new Electron.BrowserWindow(options), catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), }); - }, + }), main: liveMain, currentMainOrFirst, focusedMainOrFirst, - setMain: (window) => Ref.set(mainWindowRef, Option.some(window)), - clearMain: (window) => - Ref.update(mainWindowRef, (current) => { + setMain: Effect.fn("ElectronWindow.setMain")(function (window) { + return Ref.set(mainWindowRef, Option.some(window)); + }), + clearMain: Effect.fn("ElectronWindow.clearMain")(function (window) { + return Ref.update(mainWindowRef, (current) => { if (Option.isNone(current)) { return current; } @@ -250,9 +251,10 @@ export const make = Effect.gen(function* () { return current; } return Option.none(); - }), - prepareReveal: (window) => - Effect.promise(async () => { + }); + }), + prepareReveal: Effect.fn("ElectronWindow.prepareReveal")(function (window) { + return Effect.promise(async () => { if (platform !== "win32" || window.isDestroyed()) { return false; } @@ -260,9 +262,10 @@ export const make = Effect.gen(function* () { return ensureWindowsForegroundFocus() .prepare(windowsForegroundFocusTarget(window)) .catch(() => false); - }), - reveal: (window) => - Effect.tryPromise({ + }); + }), + reveal: Effect.fn("ElectronWindow.reveal")(function (window) { + return Effect.tryPromise({ try: async () => { if (window.isDestroyed()) { return; @@ -323,7 +326,8 @@ export const make = Effect.gen(function* () { channel: null, cause, }), - }).pipe(Effect.orDie), + }).pipe(Effect.orDie); + }), sendAll: /* Window broadcasts run for streamed updates; avoid a span per broadcast. */ Effect.fnUntraced(function* (channel, ...args) { diff --git a/apps/desktop/src/ipc/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.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index b5fe707f4caee..ed821165b40f7 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -20,9 +20,13 @@ vi.mock("electron", () => ({ import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getRelayTelemetryEnabled, + pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -192,6 +196,78 @@ describe("getRelayTelemetryEnabled", () => { ); }); +describe("pickProjectFavicon", () => { + const pickerLayer = ( + pickFiles: () => Effect.Effect>, + settings?: DesktopAppSettings.DesktopSettings, + ) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeedNone, + }), + DesktopAppSettings.layerTest(settings), + ); + + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: [ + "avif", + "bmp", + "gif", + "ico", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "svg", + "tif", + "tiff", + "webp", + "icns", + ], + }, + ], + }, + ], + ]); + }), + ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); +}); + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 9066cf6f1a67c..f0d661b58bc9a 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -138,9 +138,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 @@ -194,9 +192,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 bffa02d9ca435..4d57b30e2139a 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..898d94221b8d4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -119,9 +119,6 @@ describe("isSourceRunning", () => { const context = yield* withSourceHome(); assert.isFalse(yield* isSourceRunning(helium, context)); - // Chromium points the lock at `-`, a target that never - // exists on disk. A check that follows the link reports a running - // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", `${userDataDirectory(context)}/SingletonLock`, @@ -209,9 +206,6 @@ describe("isSourceInstalled", () => { const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Installers for native messaging hosts create an empty user-data - // directory for every Chromium fork they know about, so treating the - // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); assert.isFalse(yield* isSourceInstalled(helium, context)); @@ -307,8 +301,6 @@ describe("cookieDatabaseCandidatePaths", () => { const fileSystem = yield* FileSystem.FileSystem; const context = yield* withSourceHome(); const root = userDataDirectory(context); - // Chromium 96+ keeps sessions in Network/; a root Cookies left behind - // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live"); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); @@ -326,6 +318,237 @@ describe("cookieDatabaseCandidatePaths", () => { const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; +const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!; + +describe("Zen", () => { + it.effect("resolves Zen's native user-data roots on every supported platform", () => + run( + Effect.gen(function* () { + const darwinContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/Users/zen-user" }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const windowsContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { + HOME: "C:\\Users\\zen-user", + APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming", + }), + Effect.provideService(HostProcess.Platform, "win32"), + ); + const linuxContext = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + + assert.equal(zen.name, "Zen"); + assert.equal(zen.engine, "firefox"); + assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]); + assert.equal( + zen.userDataDirectory(darwinContext), + darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"), + ); + assert.equal( + zen.userDataDirectory(windowsContext), + windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"), + ); + assert.equal( + zen.userDataDirectory(linuxContext), + linuxContext.path.join("/home/zen-user", ".config", "zen"), + ); + assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [ + linuxContext.path.join("/home/zen-user", ".zen"), + linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"), + ]); + }), + ), + ); + + it.effect("discovers Zen profiles through its Firefox-compatible metadata", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + const root = zen.userDataDirectory(context)!; + yield* fileSystem.makeDirectory(root, { recursive: true }); + const profile = context.path.join(root, "Profiles", "zen.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Zen", + "IsRelative=1", + "Path=Profiles/zen.default", + "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg", + "Default=1", + "", + "[Profile1]", + "Name=Empty", + "IsRelative=1", + "Path=Profiles/empty.default", + "", + ].join("\n"), + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { + directory: context.path.join("Profiles", "zen.default"), + name: "Zen", + cookieCount: 2, + }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.isFalse(yield* isSourceRunning(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("discovers Zen profiles in both current and legacy Linux roots", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const currentRoot = context.path.join(home, ".config", "zen"); + const legacyRoot = context.path.join(home, ".zen"); + const currentProfile = context.path.join(currentRoot, "current.default"); + const legacyProfile = context.path.join(legacyRoot, "legacy.default"); + + for (const [root, profile, name] of [ + [currentRoot, currentProfile, "Current"], + [legacyRoot, legacyProfile, "Legacy"], + ] as const) { + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + `Name=${name}`, + "IsRelative=1", + `Path=${context.path.basename(profile)}`, + ].join("\n"), + ); + } + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: "current.default", name: "Current", cookieCount: 1 }, + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, legacyProfile), + context.path.join(legacyProfile, "cookies.sqlite"), + ); + + yield* fileSystem.writeFileString( + context.path.join(currentRoot, "profiles.ini"), + `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`, + ); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Shared", cookieCount: 1 }, + ]); + + yield* fileSystem.remove(currentRoot, { recursive: true }); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: legacyProfile, name: "Legacy", cookieCount: 1 }, + ]); + }), + ), + ); + + it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen"); + const profile = context.path.join(root, "flatpak.default"); + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n", + ); + + assert.deepEqual(yield* listSourceProfiles(zen, context), [ + { directory: profile, name: "Flatpak", cookieCount: 2 }, + ]); + assert.isTrue(yield* isSourceInstalled(zen, context)); + assert.equal( + yield* resolveCookieDatabase(zen, context, profile), + context.path.join(profile, "cookies.sqlite"), + ); + }), + ), + ); + + it.effect("does not mistake Firefox Snap installs for Zen profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const zenRoot = context.path.join(home, ".config", "zen"); + const snapRoot = context.path.join( + home, + "snap", + "firefox", + "common", + ".mozilla", + "firefox", + ); + const zenProfile = context.path.join(zenRoot, "zen.default"); + const snapProfile = context.path.join(snapRoot, "snap.default"); + for (const root of [zenRoot, snapRoot]) { + const profile = root === zenRoot ? zenProfile : snapProfile; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0); + yield* fileSystem.writeFileString( + context.path.join(root, "profiles.ini"), + [ + "[Profile0]", + "Name=Personal", + "IsRelative=1", + `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`, + ].join("\n"), + ); + } + + const profiles = yield* listSourceProfiles(zen, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["zen.default"], + ); + assert.isTrue(yield* isSourceInstalled(zen, context)); + }), + ), + ); +}); + describe("Firefox Snap profiles", () => { it.effect.skipIf(!symlinksSupported)( "finds Snap profiles with or without profiles.ini and checks their locks", @@ -565,14 +788,9 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.writeFileString(`${root}/lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // `.parentlock` is deliberately left on disk after a clean exit as a - // last-used marker, so an unlocked one is not evidence of a running - // browser — treating it as one blocked every import after first use. yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); - // The `lock` symlink is what Firefox removes on exit; a live pid in - // its target means the profile is held. yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); assert.isTrue(yield* isSourceRunning(firefox, context)); }), @@ -582,11 +800,7 @@ describe("isSourceRunning for Firefox", () => { it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => Effect.gen(function* () { const alive = (pid: number) => Effect.succeed(pid === 4242); - // The resolver may hand Firefox any of the machine's addresses, not - // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address - // elsewhere — so every local address counts as ours. const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); - // Both the plain and the fcntl-marked (`+`) forms carry the pid. assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); @@ -603,8 +817,7 @@ describe("isSourceRunning for Firefox", () => { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); const context = yield* sourcePathContext.pipe( - // Firefox's win32 root hangs off %APPDATA%; without it the root is - // undefined and the fixture would escape the sandbox into the repo. + // Without APPDATA, the Windows Firefox fixture would escape into the repo. Effect.provideService(HostProcess.Environment, { HOME: home, APPDATA: `${home}/AppData/Roaming`, @@ -616,8 +829,6 @@ describe("isSourceRunning for Firefox", () => { yield* fileSystem.makeDirectory(profile, { recursive: true }); yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); - // On Windows, Firefox creates parent.lock as a regular file that - // persists after the process exits. yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); assert.isFalse(yield* isSourceRunning(firefox, context)); }), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 3e1cae508a69e..14c4bca77b7e6 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -26,6 +26,7 @@ export interface BrowserImportSourceDefinition { readonly engine: BrowserImportEngine; readonly platforms: ReadonlyArray; readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + readonly alternateLinuxRoots?: (context: BrowserImportPathContext) => ReadonlyArray; readonly keychainService?: string; readonly keychainAccount?: string; readonly linuxSecretApplication?: string; @@ -73,9 +74,7 @@ const chromiumSource = (input: { }); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - // No Chromium fork is importable on Windows: since Chrome 127 their cookies - // are encrypted to the browser's own identity (App-Bound Encryption), so no - // other process can read them. + // Chrome 127+ App-Bound Encryption prevents other processes from reading Windows Chromium cookies. chromiumSource({ id: "chrome", name: "Chrome", @@ -171,6 +170,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray [ + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ], + }, + { + id: "zen", + name: "Zen", + engine: "firefox", + platforms: ["darwin", "win32", "linux"], + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, "zen"); + if (context.platform === "win32") { + return context.appData ? context.path.join(context.appData, "zen") : undefined; + } + return context.path.join(context.home, ".config", "zen"); + }, + alternateLinuxRoots: (context) => [ + context.path.join(context.home, ".zen"), + context.path.join(context.home, ".var", "app", "app.zen_browser.zen", "zen"), + ], }, ]; @@ -190,8 +209,7 @@ const cookieDatabaseCandidatePaths = ( if (definition.engine === "safari") { return [context.path.join(profilePath, "Cookies.binarycookies")]; } - // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade - // leaves the legacy file behind, so prefer the current one and fall back. + // Prefer Chromium 96+'s Network/Cookies; upgrades leave the pre-96 Cookies file behind. return [ context.path.join(profilePath, "Network", "Cookies"), context.path.join(profilePath, "Cookies"), @@ -209,11 +227,7 @@ export const resolveCookieDatabase = Effect.fnUntraced(function* ( return undefined; }); -/** - * Firefox records its profiles in `profiles.ini`. `Install*` sections point at - * a default profile but do not describe one, so only `[ProfileN]` blocks - * count. - */ +/** Install* sections select defaults, not profiles; only ProfileN blocks describe profiles. */ export function parseFirefoxProfiles( ini: string, path: Path.Path, @@ -242,8 +256,7 @@ export function parseFirefoxProfiles( if (!escapesRoot) directory = path.normalize(candidate); } } else if (path.isAbsolute(candidate)) { - // Firefox supports profiles on arbitrary custom roots when - // IsRelative=0. Do not constrain them to the standard Firefox root. + // IsRelative=0 permits custom roots outside Firefox's standard directory. directory = path.normalize(candidate); } @@ -308,10 +321,7 @@ const isSafeProfileDirectory = (directory: string): boolean => const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); -/** - * Firefox containers use identities Electron cannot represent, so its count - * uses the same default-container predicate as the reader. - */ +// Count only default-container cookies because Electron cannot represent Firefox containers. const countProfileCookies = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -420,12 +430,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - // `profiles.ini` also lists profiles the installer created but the user - // never launched, which hold no cookie database and nothing to import. When - // none of the declared profiles has one, fall through to the scan rather - // than returning empty: `profiles.ini` can list stale or never-launched - // profiles while the cookies live in one it does not mention, and an empty - // answer here hides the browser entirely. + // Declared profiles may be stale or never launched; scan for unlisted stores if none has a cookie database. if (declared.length > 0) { const found = yield* Effect.forEach(declared, (profile) => Effect.forEach( @@ -460,11 +465,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), - // The keys are directory names from the browser's own metadata file, which - // anything running as the user can write. Anything but a single plain - // segment is dropped: `..` or a path separator would otherwise be handed - // to `cookieDatabasePath` and read a database outside the user-data - // directory. + // Browser metadata is user-writable; reject traversal segments before resolving cookie database paths. Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), Effect.map((entries) => entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), @@ -490,11 +491,7 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( ); }); -/** - * Include Firefox's Snap home alongside its native home. Snap profiles use - * absolute directories so cookie reads and lock checks keep pointing at the - * installation they came from, even when both installs use the same name. - */ +/** Keep alternate-root profile paths absolute so cookie reads and locks use the correct installation. */ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -505,10 +502,7 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf const root = definition.userDataDirectory(context); if (root === undefined) return []; - const roots = [ - root, - context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), - ]; + const roots = [root, ...(definition.alternateLinuxRoots?.(context) ?? [])]; const profiles = new Map(); for (const directory of roots) { const found = yield* listSourceProfilesInDirectory( @@ -544,9 +538,7 @@ export const chromiumProcessIsAlive = ( signalProcess(pid, 0); return true; } catch (cause) { - // Only ESRCH positively proves the process is gone. Permission errors - // and unknown failures stay conservative so an active browser is never - // mistaken for a stale lock. + // Only ESRCH proves a stale lock; permission and unknown failures may still hide an active browser. return !( typeof cause === "object" && cause !== null && @@ -570,8 +562,7 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( if (!/^\d+$/.test(pidText)) return true; const pid = Number(pidText); if (!Number.isSafeInteger(pid) || pid <= 0) return true; - // A PID is meaningful only on this host. A foreign hostname can come from a - // shared home directory, and cannot safely be declared stale from here. + // Foreign-host PIDs may come from a shared home; never declare them stale here. if (host !== currentHost) return true; return yield* isProcessAlive(pid); }); @@ -580,15 +571,9 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => error.reason._tag === "Busy"; -/** - * Whether a Windows `parent.lock` is actually held by a running process. It - * is opened with no sharing, so it persists on disk after the process exits - * and `stat` always succeeds; only trying to open it for write reveals an - * active holder, which surfaces as `Busy`. - */ +/** Windows leaves parent.lock after exit; only opening it for write reveals a live holder as Busy. */ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { - // Permission failures are distinct: they do not prove a browser owns the - // lock, so they must not hide the source as running. + // Permission failures do not prove a running browser owns the lock. const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( Effect.as(false), @@ -600,11 +585,7 @@ const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { type WindowsLockProbe = (path: string) => Effect.Effect; -/** - * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The - * live cookie database is opened without sharing instead, so probing each - * profile's current jar is the reliable running signal there. - */ +/** Windows Chromium locks cookie databases without sharing instead of creating POSIX SingletonLock symlinks. */ export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, @@ -628,9 +609,7 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( ) { const separator = target.lastIndexOf(":"); if (separator < 0) return true; - // The owner half is whatever Firefox's resolver returned for the machine's - // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN - // address — so a pid is only meaningful when that address is one of ours. + // Firefox resolves the hostname to loopback or LAN addresses; only local owners' PIDs are meaningful. const owner = target.slice(0, separator); if (!localAddresses.has(owner)) return true; // A `+` marks an fcntl-holding owner; the pid follows either way. @@ -641,18 +620,10 @@ export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); -/** - * `/usr/bin/python3` is named absolutely first so a Dock-launched app with - * launchd's bare `PATH` still finds it without depending on the login-shell - * PATH merge; Linux distributions carry python3 on the default path. - */ +/** Dock launches have a bare PATH; try the absolute interpreter before relying on shell discovery. */ const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; -/** - * Anything else means the script never ran — most importantly Apple's - * `/usr/bin/python3` shim, which on a Mac without the Command Line Tools exits - * non-zero after printing an install prompt, without ever reaching our code. - */ +/** Apple's python3 shim can fail with an install prompt before the probe script runs. */ const FCNTL_PROBE_SCRIPT = "import fcntl,os,sys\n" + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + @@ -664,12 +635,9 @@ const FCNTL_PROBE_SCRIPT = " print('free')"; /** - * Anything else — no interpreter on any candidate path, or one that refused to - * run the script (Apple's shim without the developer tools) — is the probe - * being unavailable, not evidence about the lock. That case falls back to "not - * held" rather than "held": reporting every profile as locked forever would - * block Firefox import outright on such machines, and the SQLite snapshot - * already copes with a live database's WAL, as it does for every other engine. + * A missing or refusing interpreter (Apple's shim without developer tools) means + * the probe is unavailable, so report "not held": "held" would block Firefox import + * forever, and the SQLite snapshot already copes with a live WAL. */ const posixLockIsHeld = Effect.fnUntraced(function* ( path: string, @@ -699,12 +667,7 @@ const posixLockIsHeld = Effect.fnUntraced(function* ( return false; }); -/** - * But `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files - * held with fcntl or a Windows handle and are *deliberately left on disk* after - * exit, as a last-used marker; treating them as proof of a running browser - * blocks every import after Firefox has been used once. - */ +/** Firefox leaves regular lock files after exit; test the lock, not file existence. */ const firefoxProfileIsHeld = Effect.fnUntraced(function* ( directory: string, context: BrowserImportPathContext, @@ -739,8 +702,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const fileSystem = yield* FileSystem.FileSystem; const root = definition.userDataDirectory(context); if (root === undefined) return false; - // Safari keeps no lock and writes its jar atomically, so a running instance - // is not a hazard there. + // Safari writes its jar atomically and needs no lock, even while running. if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { @@ -766,18 +728,7 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") return found.some(Boolean); }); -/** - * Keyed off the cookie database rather than the user-data directory, because - * that directory is not evidence the browser exists: installers for native - * messaging hosts create an empty one for every Chromium fork they know about, - * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as - * present. - * - * Existence is checked without opening the file, which matters for Safari: TCC - * permits `stat` on the jar inside its container but refuses a read, so this - * still sees it and the user gets the Full Disk Access prompt rather than - * having Safari disappear. - */ +/** Require a cookie database; installers create empty directories. Safari stat needs no Full Disk Access. */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, context: BrowserImportPathContext, diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 45d2aeafdbfac..d9b797dd0451f 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -85,8 +85,6 @@ describe("BrowserSession", () => { Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; - // TextEncoder folds a lone surrogate to U+FFFD, so without escaping these - // two supported ids would hash to one partition and share every cookie. const loneSurrogate = yield* browserSessions.getPartition("p\ud800"); const replacementChar = yield* browserSessions.getPartition("p\ufffd"); assert.notStrictEqual(loneSurrogate, replacementChar); @@ -116,13 +114,8 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); - // A rewritten session UA — any variant, even ones that keep the Electron - // token — makes Cloudflare Turnstile loop with error 600010 (#5002), so the - // guest must end up with Electron's native User-Agent. it.effect("keeps the guest's effective User-Agent equal to Electron's native one", () => Effect.gen(function* () { - // Electron's real UA shape: app token, then Chrome, then Electron, then - // Safari — the token order and casing matter to any strip regex. const nativeUserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36"; fromPartition.mockReset(); 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..1c1f9dc078ca0 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -65,8 +65,8 @@ describe("CDP relay", () => { ]); pending[0]!(); await settle(); - expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined); - expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session"); + expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined, undefined); + expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session", undefined); expect(written.at(-1)).toEqual({ id: 5, result: { method: "Page.captureScreenshot" }, @@ -92,7 +92,30 @@ describe("CDP relay", () => { }), ); await settle(); - expect(send.mock.calls).toEqual([["Runtime.runIfWaitingForDebugger", {}, "iframe-session"]]); + expect(send.mock.calls).toEqual([ + ["Runtime.runIfWaitingForDebugger", {}, "iframe-session", undefined], + ]); + }); + + // Playwright enables focus emulation on every page it connects to, and Chromium then + // counts the guest as captured: a preview the panel hides keeps its timers unthrottled. + it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => { + const send = vi.fn(async () => ({})); + const written: Array> = []; + const relay = createCdpRelayConnection(makeTarget(send), (raw) => + written.push(JSON.parse(raw)), + ); + relay.receive( + JSON.stringify({ + id: 1, + method: "Emulation.setFocusEmulationEnabled", + params: { enabled: true }, + sessionId: "t3-preview-page", + }), + ); + await settle(); + expect(written).toEqual([{ id: 1, result: {}, sessionId: "t3-preview-page" }]); + expect(send).not.toHaveBeenCalled(); }); it("routes tab events to the page session once the page is announced", async () => { @@ -104,7 +127,6 @@ describe("CDP relay", () => { expect(written).toEqual([]); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); await settle(); - // Electron passes an empty session id for the page's own events. relay.event("Page.loadEventFired", { timestamp: 1 }, ""); relay.event("Runtime.consoleAPICalled", {}, "iframe-session"); expect(written.slice(-2)).toEqual([ @@ -148,7 +170,7 @@ describe("CDP relay extra sessions", () => { expect(written.find((m) => m["id"] === 4)).toHaveProperty("error"); relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra })); await settle(); - expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined); + expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; relay.event("Page.screencastFrame", { data: "x" }, undefined); expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 581b692882b30..948bc361f5d92 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -1,23 +1,20 @@ -/** - * Electron allows one debugger client per webContents, so the relay shares the - * attach the preview manager already holds. - */ +// Electron allows one debugger per webContents; share the manager's attachment. export interface CdpRelayTarget { readonly send: ( method: string, params: Record, sessionId: string | undefined, + /** The extra session that sent it, such as a viewer's; the main connection has none. */ + owner?: string, ) => Promise; + readonly sessionDetached?: (owner: string) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; readonly title: () => string; readonly userAgent: () => string; - /** - * The desktop app and the server it launched share a disk, so the server's - * Playwright reads the file where it asked for it. - */ + /** The desktop and its server share a disk; download paths need no translation. */ readonly setDownloadDirectory: (directory: string | null) => void; } @@ -56,11 +53,7 @@ export function createCdpRelayConnection( ): CdpRelayConnection { const send = (message: Record) => write(JSON.stringify(message)); let attached = false; - /** - * Sessions this relay invented. Extra page sessions (Playwright's - * `newCDPSession`) all reach the one real debugger session, so each gets the - * page's events; other ids are the page's own child sessions, such as iframes. - */ + /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { @@ -121,8 +114,10 @@ export function createCdpRelayConnection( } case "Target.detachFromTarget": { const sessionId = command.params?.["sessionId"]; - if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) + if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + target.sessionDetached?.(sessionId); + } return {}; } case "Target.setAutoAttach": { @@ -143,14 +138,15 @@ export function createCdpRelayConnection( const pageCommand = (command: CdpCommand): Promise => { const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId; - // The page itself is already running, but its child frames are not: - // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses - // every new cross-site iframe and worker until this reaches it. Swallowing - // it left sign-in checks and captchas, which run in such frames, frozen. + const owner = + child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined; + // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command. if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) { return Promise.resolve({}); } - return target.send(command.method, command.params ?? {}, child); + // Playwright asks for this on every page; the host emulates focus only while it is in use. + if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + return target.send(command.method, command.params ?? {}, child, owner); }; return { @@ -167,8 +163,7 @@ export function createCdpRelayConnection( ? browserCommand(command) : pageCommand(command); const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; - // Replies leave as commands finish, as Chromium's do, so a slow command - // such as a screenshot never holds up the ones behind it. + // Reply as commands finish so slow commands cannot block later replies. void result.then( (value) => { const announce = diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 50039c4e4b04e..08fefd8d733f5 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -30,18 +31,39 @@ const command = ( }), ); -const makeDebuggee = () => { +const makeDebuggee = ( + options: { + readonly deferFocus?: boolean; + readonly refuse?: (method: string, sessionId: string | undefined) => boolean; + } = {}, +) => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<() => void> = []; + const focusChanges: Array<() => void> = []; const sent: Array = []; const sendWaiters: Array<() => void> = []; + const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { - sendCommand: (method: string, params?: { id?: number }) => - new Promise((resolve) => { + sendCommand: ( + method: string, + params?: { id?: number; enabled?: boolean }, + sessionId?: string, + ) => + new Promise((resolve, reject) => { + if (options.refuse?.(method, sessionId)) { + reject(new Error("Refused")); + return; + } if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } + if (method === "Emulation.setFocusEmulationEnabled") { + focusEmulation.push(params?.enabled === true); + if (options.deferFocus) focusChanges.push(() => resolve({})); + else resolve({}); + return; + } sent.push(params?.id); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); @@ -61,12 +83,18 @@ const makeDebuggee = () => { navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), releaseFirst: () => pending.shift()?.(), + settleFocus: () => focusChanges.splice(0).forEach((resolve) => resolve()), }; }; +/** Commands wait behind a focus emulation change; it settles within a macrotask. */ +// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time +const settle = Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) => host.events.pipe( Stream.take(count), @@ -93,6 +121,18 @@ const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => return replies; }); +/** Whether the client should paint the guest, each time that changes. */ +const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => + Effect.gen(function* () { + const painting: Array = []; + yield* host.painting.pipe( + Stream.runForEach(({ active }) => Effect.sync(() => painting.push(active))), + Effect.forkScoped, + ); + yield* Effect.yieldNow; + return painting; + }); + describe("DesktopBrowserHost", () => { it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { @@ -123,6 +163,7 @@ describe("DesktopBrowserHost", () => { yield* command(1, "Page.captureScreenshot"); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); + yield* settle; debuggee.release(); const [, reply] = yield* Fiber.join(reader); @@ -141,6 +182,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "DOM.enable"); + yield* settle; expect(debuggee.sent).toEqual([1, 3]); debuggee.releaseFirst(); @@ -159,6 +201,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; debuggee.navigate(); expect(debuggee.sent).toEqual([1, 2]); @@ -181,6 +224,7 @@ describe("DesktopBrowserHost", () => { yield* command(host, 1, "Page.captureScreenshot"); yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(host, 3, "Page.captureScreenshot"); expect(debuggee.sent).toEqual([1]); @@ -192,6 +236,682 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("emulates focus while the server drives a guest, then lets a hidden one throttle", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true]); + expect(debuggee.sent).toEqual([1]); + debuggee.release(); + yield* settle; + + yield* TestClock.adjust("4 seconds"); + yield* command(host, 2, "Input.dispatchMouseEvent"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("4 seconds"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* TestClock.adjust("10 seconds"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.focusEmulation).toEqual([true, false, true]); + expect(debuggee.sent).toEqual([1, 2, 3]); + }), + ); + + it.effect("keeps a guest focused while a command it is running is still pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + // An evaluation awaiting the page's next frame, which a throttled guest never paints. + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Runtime.evaluate"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A command the old document never answers. + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + debuggee.navigate(); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused for the screenshot a navigation lets start", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + expect(debuggee.sent).toEqual([1, 2]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }), + ); + + it.effect("never sends a screenshot a navigation superseded while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + debuggee.settleFocus(); + yield* settle; + // Two captures in flight would undo the one-at-a-time guard. + expect(debuggee.sent).toEqual([2]); + }), + ); + + it.effect("keeps a guest focused when its turn starts after the agent's first command", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + yield* turn(true); + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("lets a guest throttle through a turn that only sent it housekeeping", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // Playwright re-enabling a domain after a reload is not the agent using the tab. + yield* command(host, 1, "Runtime.enable"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + yield* command(host, 2, "Runtime.evaluate"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }), + ); + + it.effect("keeps a guest the agent used focused through its turn's pauses", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([]); + + yield* command(host, 1, "Input.dispatchKeyEvent"); + yield* settle; + debuggee.release(); + yield* settle; + // The model thinks between steps; a focus-sensitive menu must not blur meanwhile. + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect( + "ends a command's hold with the turn that sent it, even if Chromium never answers", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + + // A screenshot of a hidden guest the server already gave up on. + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + expect(debuggee.sent).toEqual([1]); + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: false }), + ); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }), + ); + + it.effect("ends the agent's own session's holds with its turn, but not a viewer's stream", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server's own CDP session, evaluating a promise that never settles. + yield* turn(true); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Runtime.evaluate", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer streaming the guest keeps it focused through a turn's end. + yield* turn(true); + yield* raw({ id: 3, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(3); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + yield* turn(false); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true]); + }).pipe(Effect.scoped), + ); + + it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + expect(painting).toEqual([true, false]); + + // The agent's screenshot waits its turn; painting lets the abandoned one finish first. + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true, false, true]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.sent).toEqual([undefined, 4]); + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a queued screenshot once its session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The agent's screenshot is abandoned at turn end; a viewer's waits behind it, then leaves. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(painting).toEqual([true, false, true]); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(4); + expect(painting).toEqual([true, false, true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot whose session detached while focus was changing", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + expect(debuggee.sent).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The first answers late; the second still goes in turn, but holds nothing. + debuggee.releaseFirst(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2]); + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ refuse: (method) => method === "Page.startScreencast" }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(painting).toEqual([true, false]); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* host.handleCommandLine( + encodeJson({ type: "turn", threadId: key.threadId, active: true }), + ); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 3, method: "Input.dispatchMouseEvent", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a page's screencast when a child session's screencast is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ + refuse: (method, sessionId) => method === "Page.startScreencast" && sessionId !== undefined, + }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* command(host, 1, "Page.startScreencast"); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "child-frame" }); + expect(yield* Queue.take(replies)).toBe(2); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + }).pipe(Effect.scoped), + ); + + it.effect("ignores a screencast that arrives on a session after it detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(2); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("lets a guest throttle once the session waiting on its command detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // A viewer's own CDP session, closed while its still of a hidden guest is pending. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("never sends a screenshot queued by a session that has since detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* command(host, 2, "Page.captureScreenshot"); + yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 4, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(4); + // The dropped screenshot's error reply leaves as the first frees its turn. + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([3, 2]); + yield* settle; + expect(debuggee.sent).toEqual([2]); + + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a streamed guest focused until its screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + + yield* command(host, 1, "Page.startScreencast"); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* command(host, 2, "Page.stopScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + + // A viewer whose connection went away without stopping its screencast. + yield* command(host, 3, "Page.startScreencast"); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false, true, false]); + }), + ); + + it.effect("keeps a guest focused while any of its screencasts runs", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + // Two viewers, or a viewer and a recording, each on its own session. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* TestClock.adjust("1 minute"); + expect(debuggee.focusEmulation).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + + yield* command(host, 1, "DOM.enable"); + yield* settle; + expect(painting).toEqual([]); + + yield* command(host, 2, "Page.captureScreenshot"); + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + expect(painting).toEqual([true]); + + // The first answers once the guest paints; the queued one then takes its turn. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1, 2, 3]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("stops painting for a screenshot Chromium never answers", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const painting = yield* watchPainting(host); + const turn = (active: boolean) => + host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); + + // The server gave up on it, and the agent's turn ends. + yield* turn(true); + yield* command(host, 1, "Page.captureScreenshot"); + yield* settle; + yield* turn(false); + yield* settle; + expect(painting).toEqual([true, false]); + + // The page loads a new document, which also frees the screenshot turn. + debuggee.navigate(); + yield* command(host, 2, "Page.captureScreenshot"); + yield* settle; + debuggee.navigate(); + yield* settle; + expect(painting).toEqual([true, false, true, false]); + + // The server releases the relay. + yield* command(host, 3, "Page.captureScreenshot"); + yield* settle; + yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false]); + + // The guest detaches; a replacement starts with no screenshot pending. + const replacement = makeDebuggee(); + host.attach(key, replacement.tab); + yield* command(host, 4, "Page.captureScreenshot"); + yield* settle; + host.detach(key); + yield* settle; + expect(painting).toEqual([true, false, true, false, true, false, true, false]); + }).pipe(Effect.scoped), + ); + + it.effect("paints a streamed guest until its last screencast ends", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const painting = yield* watchPainting(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + debuggee.release(); + yield* settle; + expect(painting).toEqual([true]); + + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + yield* settle; + expect(painting).toEqual([true, false]); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2d..30f252e3a677e 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Names download files on the shared disk. -/** - * The preview manager owns the tab's single debugger session and hands it here; - * the relay shares it. - */ +/** The manager owns the single debugger attachment; the relay shares it. */ import { DesktopBrowserCommand, DesktopBrowserEvent, @@ -25,7 +22,10 @@ const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBr const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; +/** The same commands as above, by method, for the relay's sends. */ +const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +const FOCUS_IDLE = "5 seconds"; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -44,10 +44,33 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - readonly waitingScreenshots: Array<() => void>; + /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + readonly waitingScreenshots: Array<{ + readonly start: () => void; + readonly live: () => boolean; + held: boolean; + }>; downloadDirectory: string | null; pendingDownloadGuid: string | null; agentInputAt: number; + focusEmulated: boolean; + focusChange: Promise | null; + focusUses: number; + /** Each pending command, by the extra session waiting on it; the main connection's have none. */ + readonly focusPending: Map< + object, + { readonly owner: string | undefined; readonly paints: boolean } + >; + /** Whether the client keeps the guest painting, as published on `painting`. */ + painting: boolean; + /** + * Driven by the agent since its thread's last turn ended or the guest last throttled. A + * turn's start can reach us after its first command, so this holds focus once it does. + */ + agentUsed: boolean; + focusReleasing: boolean; + /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ + readonly screencasts: Set; readonly onMessage: ( event: Electron.Event, method: string, @@ -72,6 +95,11 @@ export class DesktopBrowserHost extends Context.Service< readonly x: number; readonly y: number; }>; + /** Whether a guest needs its frames painted while the panel hides it. */ + readonly painting: Stream.Stream<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>; } >()("@t3tools/desktop/preview/DesktopBrowserHost") {} @@ -83,44 +111,192 @@ export const make = Effect.gen(function* () { readonly x: number; readonly y: number; }>(16); + const painting = yield* PubSub.unbounded<{ + readonly key: DesktopBrowserTabKey; + readonly active: boolean; + }>(); const runFork = Effect.runForkWith(yield* Effect.context()); const tabs = new Map(); + /** Threads whose agent turn runs, by the server's account. */ + const agentTurns = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { tab.screenshotTurn = null; - tab.waitingScreenshots.shift()?.(); + tab.waitingScreenshots.shift()?.start(); + releaseFocusWhenIdle(tab); + syncPainting(tab); }; /** * Chromium below 157 crashes the whole app when a guest's renderer exits with a * screenshot copy in flight (crbug.com/567321049). Overlapping screenshots make that * far likelier, and one the server gave up on stays pending in Chromium, so each guest - * gets one at a time. One queued by a relay released since then is dropped unsent. + * gets one at a time. One whose relay or session has since gone is dropped unsent. */ const takeScreenshot = ( tab: AttachedTab, relay: CdpRelayConnection, - capture: () => Promise, + ownerLive: () => boolean, + /** Sends only while `current` holds; holds focus and painting only while `held`. */ + capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { - const start = () => { - if (tab.relay !== relay) { - reject(new Error("The preview connection that asked for this screenshot closed.")); - nextScreenshot(tab); + const live = () => tab.relay === relay && ownerLive(); + const waiting = { + start: () => { + if (!live()) { + reject(new Error("The preview connection that asked for this screenshot closed.")); + nextScreenshot(tab); + return; + } + const turn = {}; + tab.screenshotTurn = turn; + capture(() => tab.screenshotTurn === turn && live(), waiting.held) + .finally(() => { + if (tab.screenshotTurn === turn) nextScreenshot(tab); + }) + .then(resolve, reject); + }, + live, + held: true, + }; + if (tab.screenshotTurn === null) waiting.start(); + else { + // The one ahead may be abandoned mid-capture; it needs frames to finish and free its turn. + tab.waitingScreenshots.push(waiting); + setFocusEmulation(tab, true); + syncPainting(tab); + } + }); + + /** + * Chromium counts a focus-emulated guest as captured, so a guest the panel hides would + * never throttle. Without it, a hidden guest paints no frames and focus() fires no focus + * events, so the guest is emulated focused while the server drives it or streams it. + */ + const setFocusEmulation = (tab: AttachedTab, enabled: boolean) => { + if (tab.focusEmulated === enabled) return; + tab.focusEmulated = enabled; + // A guest that refuses it, or a debugger already gone, behaves as unfocused. + const change: Promise = tab.debuggee.debugger + .sendCommand("Emulation.setFocusEmulationEnabled", { enabled }) + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (tab.focusChange === change) tab.focusChange = null; + }); + tab.focusChange = change; + }; + + const waitingHolds = (tab: AttachedTab) => + tab.waitingScreenshots.some((waiting) => waiting.held && waiting.live()); + + const releaseFocusWhenIdle = (tab: AttachedTab) => { + if (tab.focusReleasing) return; + tab.focusReleasing = true; + runFork( + Effect.gen(function* () { + let uses: number; + do { + uses = tab.focusUses; + yield* Effect.sleep(FOCUS_IDLE); + } while (uses !== tab.focusUses); + tab.focusReleasing = false; + // The last pending command to settle starts another idle wait. + if (tab.focusPending.size > 0 || tab.screencasts.size > 0 || waitingHolds(tab)) { return; } - const turn = {}; - tab.screenshotTurn = turn; - capture() - .finally(() => { - if (tab.screenshotTurn === turn) nextScreenshot(tab); - }) - .then(resolve, reject); - }; - if (tab.screenshotTurn === null) start(); - else tab.waitingScreenshots.push(start); + // The agent pauses to think between steps; its tab stays focused until the turn ends. + if (tab.agentUsed && agentTurns.has(tab.key.threadId)) return; + tab.agentUsed = false; + if (tabs.get(keyOf(tab.key)) === tab) setFocusEmulation(tab, false); + }), + ); + }; + + /** + * Electron composites no offscreen guest, so its screenshot or screencast waits for a frame + * that never comes. The client paints the guest behind the app while one is pending; the + * capture then finishes on its own, and the guest goes back offscreen once none is. + */ + const syncPainting = (tab: AttachedTab) => { + const active = + tabs.get(keyOf(tab.key)) === tab && + (tab.screencasts.size > 0 || + waitingHolds(tab) || + [...tab.focusPending.values()].some((hold) => hold.paints)); + if (tab.painting === active) return; + tab.painting = active; + runFork(PubSub.publish(painting, { key: tab.key, active })); + }; + + /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ + const holdFocus = ( + tab: AttachedTab, + method: string, + owner: string | undefined, + work: () => Promise, + ) => { + const pending = {}; + tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusUses += 1; + setFocusEmulation(tab, true); + releaseFocusWhenIdle(tab); + syncPainting(tab); + return work().finally(() => { + if (!tab.focusPending.delete(pending)) return; + tab.focusUses += 1; + releaseFocusWhenIdle(tab); + syncPainting(tab); }); + }; + + /** + * Chromium can leave a command unanswered, so the screenshot guard's release points + * also end every hold: a released relay has no one waiting, and a new document answers + * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + */ + const dropFocusHolds = (tab: AttachedTab) => { + tab.focusPending.clear(); + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + const dropRelay = (tab: AttachedTab) => { + tab.relay = null; + tab.screencasts.clear(); + tab.agentUsed = false; + dropFocusHolds(tab); + }; + + /** A detached session, such as a closed viewer, waits on nothing it sent. */ + const dropSessionHolds = (tab: AttachedTab, owner: string) => { + tab.screencasts.delete(owner); + for (const [pending, hold] of tab.focusPending) { + if (hold.owner === owner) tab.focusPending.delete(pending); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + }; + + /** + * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, + * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * session, so this cannot spare other sessions; a viewer's screencast still holds. + */ + const endAgentTurn = (threadId: string) => { + agentTurns.delete(threadId); + for (const tab of tabs.values()) { + if (tab.key.threadId !== threadId) continue; + tab.agentUsed = false; + // Still sent in turn, so the queue keeps moving, but no longer kept painting. + for (const waiting of tab.waitingScreenshots) waiting.held = false; + dropFocusHolds(tab); + } + }; const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; @@ -129,12 +305,57 @@ export const make = Effect.gen(function* () { sessionId === undefined ? sendPageCommand(webContents, debuggee, method, params) : debuggee.sendCommand(method, params, sessionId); + const detachedOwners = new Set(); + /** Viewers and recordings stream; the agent's own sessions never do. */ + const streamingOwners = new Set(); const relay: CdpRelayConnection = createCdpRelayConnection( { - send: (method, params, sessionId) => - method === "Page.captureScreenshot" - ? takeScreenshot(tab, relay, () => send(method, params, sessionId)) - : send(method, params, sessionId), + send: (method, params, sessionId, owner) => { + // A child session's screencast is not the page's, such as a late one on a detached session. + if (sessionId === undefined && method === "Page.startScreencast") { + tab.screencasts.add(owner); + if (owner !== undefined) streamingOwners.add(owner); + } else if (sessionId === undefined && method === "Page.stopScreencast") { + tab.screencasts.delete(owner); + } + // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. + if ( + AGENT_INPUT_METHOD.test(method) && + (owner === undefined || !streamingOwners.has(owner)) + ) { + tab.agentUsed = true; + } + // Waits for focus emulation; insertText and other non-CDP input would not queue behind it. + const forward = (current?: () => boolean) => { + const change = tab.focusChange; + const run = () => + current === undefined || current() + ? send(method, params, sessionId) + : Promise.reject(new Error("The page this screenshot was for has moved on.")); + return change === null ? run() : change.then(run); + }; + return method === "Page.captureScreenshot" + ? takeScreenshot( + tab, + relay, + () => owner === undefined || !detachedOwners.has(owner), + (current, held) => + held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + ) + : method === "Page.startScreencast" + ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + // A viewer can outlive a refused stream; nothing is streaming for it. + if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }) + : holdFocus(tab, method, owner, forward); + }, + sessionDetached: (owner) => { + detachedOwners.add(owner); + dropSessionHolds(tab, owner); + }, targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -156,10 +377,7 @@ export const make = Effect.gen(function* () { return relay; }; - /** - * Without a path Electron would open its Save dialog over the app for a file - * the agent asked for. CDP names the download just before this runs. - */ + // CDP supplies the path before this runs; otherwise Electron opens a Save dialog. const placeDownload = (source: Electron.WebContents, item: Electron.DownloadItem) => { const tab = [...tabs.values()].find( (candidate) => candidate.debuggee.webContents === source && candidate.downloadDirectory, @@ -178,6 +396,7 @@ export const make = Effect.gen(function* () { tab.relay = null; tab.debuggee.debugger.off("message", tab.onMessage); tab.debuggee.webContents.off("did-navigate", tab.onNewDocument); + syncPainting(tab); emit({ type: "detached", ...key }); }; @@ -194,6 +413,14 @@ export const make = Effect.gen(function* () { downloadDirectory: null, pendingDownloadGuid: null, agentInputAt: Number.NEGATIVE_INFINITY, + focusEmulated: false, + focusChange: null, + focusUses: 0, + focusPending: new Map(), + painting: false, + agentUsed: false, + focusReleasing: false, + screencasts: new Set(), onMessage: (_event, method, params, sessionId) => { if (method === "Browser.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; @@ -204,6 +431,8 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { + // Before the next screenshot starts, so its own hold survives. + dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, }; @@ -217,6 +446,11 @@ export const make = Effect.gen(function* () { Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; + if (command.value.type === "turn") { + if (command.value.active) agentTurns.add(command.value.threadId); + else endAgentTurn(command.value.threadId); + return; + } const tab = tabs.get(keyOf(command.value)); if (!tab) return; if (command.value.type === "pointer") { @@ -225,7 +459,7 @@ export const make = Effect.gen(function* () { return; } if (command.value.type === "release") { - tab.relay = null; + dropRelay(tab); return; } if (AGENT_INPUT_COMMAND.test(command.value.message)) tab.agentInputAt = performance.now(); @@ -237,19 +471,22 @@ export const make = Effect.gen(function* () { return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS; }; - const announceAll = Effect.suspend(() => - Effect.forEach( + const announceAll = Effect.suspend(() => { + // A new backend: the old one's turns ended without telling us. + agentTurns.clear(); + return Effect.forEach( [...tabs.values()], (tab) => { - tab.relay = null; + dropRelay(tab); return PubSub.publish(outbox, { type: "attached", ...tab.key }); }, { discard: true }, - ), - ); + ); + }); return DesktopBrowserHost.of({ pointers: Stream.fromPubSub(pointers), + painting: Stream.fromPubSub(painting), // Subscribes before announcing, so no attach falls between the two. events: Stream.unwrap( Effect.gen(function* () { diff --git a/apps/desktop/src/preview/FaviconCapture.ts b/apps/desktop/src/preview/FaviconCapture.ts index a2b36d182a8f3..4716645c9defa 100644 --- a/apps/desktop/src/preview/FaviconCapture.ts +++ b/apps/desktop/src/preview/FaviconCapture.ts @@ -669,8 +669,7 @@ async function rasterizeFavicon( ); if (signal.aborted) onAbort(); }); - // The logical timeout does not cancel Electron's renderer work. Keep the - // gate closed until that physical execution actually settles. + // Logical timeouts leave renderer work running; keep the gate closed until it settles. const launchAllowed = execution.then( () => undefined, () => undefined, diff --git a/apps/desktop/src/preview/GuestInput.test.ts b/apps/desktop/src/preview/GuestInput.test.ts index 6cb297ff81e90..b531182c92959 100644 --- a/apps/desktop/src/preview/GuestInput.test.ts +++ b/apps/desktop/src/preview/GuestInput.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts"; -/** A guest whose page reports `focusedDocument` for the focus probe. */ const makeGuest = ({ focused, focusedDocument = true, diff --git a/apps/desktop/src/preview/GuestInput.ts b/apps/desktop/src/preview/GuestInput.ts index 35006dfcec08b..42201b48ed61c 100644 --- a/apps/desktop/src/preview/GuestInput.ts +++ b/apps/desktop/src/preview/GuestInput.ts @@ -1,26 +1,12 @@ -/** - * Keyboard and text input for a preview tab's page that never lands outside it. - * - * Chromium sends a page's CDP key and text input to whichever frame holds focus - * in the window. A `` guest shares that focus with the app around it, - * so while a person works in the app, `Input.insertText` on the guest's own - * debugger types into the app's focused field. Such input goes through CDP only - * while the guest holds focus. Otherwise it goes straight to the guest's own - * widget, which nothing redirects. - */ +// CDP input follows shared window focus; send directly to unfocused guests to avoid typing into the app. -/** CDP input Chromium delivers to the window's focused frame rather than the page's. */ const FOCUS_ROUTED_INPUT = new Set([ "Input.insertText", "Input.dispatchKeyEvent", "Input.imeSetComposition", ]); -/** - * Page script: the document holding the page's focus, or null when a cross-site - * frame holds it. Electron's direct input reaches only the main frame's process, - * and `insertText` crashes the page when focus is in another one. - */ +/** Direct input reaches only the main-frame process; insertText crashes when a cross-site frame has focus. */ const FOCUSED_DOCUMENT = `(() => { let doc = document; for (;;) { @@ -31,11 +17,7 @@ const FOCUSED_DOCUMENT = `(() => { } })()`; -/** - * macOS editing commands a key press asks for (Cmd+A is `selectAll`), which - * Chromium runs only for CDP keys. Clipboard commands stay out: they would read - * or replace the person's clipboard. - */ +/** Electron runs editing commands only for CDP keys; exclude clipboard commands to preserve the user's clipboard. */ const EDITING_COMMANDS = new Set(["selectAll", "undo", "redo"]); const CROSS_SITE_FOCUS_MESSAGE = @@ -74,18 +56,13 @@ const MODIFIER_BITS = [ [8, "shift"], ] as const; -/** Electron's accelerator name for a DOM key value, or null when it has none. */ const keyCodeFor = (key: string) => { if ([...key].length === 1) return key; if (ARROW_KEYS[key]) return ARROW_KEYS[key]; return NAMED_KEYS.has(key) || /^F(?:[1-9]|1\d|2[0-4])$/u.test(key) ? key : null; }; -/** - * The Electron key events for one CDP `Input.dispatchKeyEvent`. Electron - * derives key, code, and a char's text from `keyCode`, so typed text travels - * as `char` events of its own. - */ +/** Electron derives text from keyCode, so typed text needs separate char events. */ export const toKeyboardInputEvents = ( params: Record, ): Array => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6ded53bfaf1ba..f19079e46bce4 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -80,8 +80,6 @@ describe("previewWindowOpenAction", () => { }); it("opens a real window for scripted popups so the opener survives", () => { - // OAuth SDKs read a null `window.open()` as a blocked popup, and they need - // the opener alive to receive the credential back. expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup"); expect( PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })), @@ -89,7 +87,6 @@ describe("previewWindowOpenAction", () => { }); it("keeps a form POST with a body on the in-place path", () => { - // A new tab can only reopen the URL as a GET, which would drop the body. const postBody = { data: [], contentType: "application/x-www-form-urlencoded" }; expect( PreviewManager.previewWindowOpenAction({ @@ -100,9 +97,6 @@ describe("previewWindowOpenAction", () => { }); it("does not hand a window to schemes that cannot be hardened", () => { - // A popup skips the `will-attach-webview` hardening, so it only gets a window - // when its preferences can be overridden. Chromium copies the guest's - // preferences for `about:blank` and forbids overriding them. for (const url of [ "javascript:alert(1)", "file:///etc/passwd", @@ -114,8 +108,6 @@ describe("previewWindowOpenAction", () => { }); it("denies a blank window instead of blanking the opener", () => { - // MSAL opens `about:blank` first and falls back to a redirect on `null`; - // loading the blank page into the tab would destroy that fallback. for (const url of ["about:blank", ""]) { for (const disposition of ["new-window", "foreground-tab"] as const) { expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny"); @@ -155,6 +147,7 @@ const { vi.mock("electron", () => ({ BrowserWindow: browserWindowConstructor, + // oxlint-disable-next-line typescript/no-extraneous-class -- This constructor-only mock implements the browser ClipboardItem API. ClipboardItem: class { constructor(data: Record) { clipboardItemConstructor(data); @@ -249,7 +242,6 @@ const withManager = ( interface TestCapturedPreviewImage { readonly toJPEG: () => Buffer; - /** Only fixtures that deliver picture-in-picture frames need pixels to fingerprint. */ readonly toBitmap?: () => Buffer; readonly getSize: () => { readonly width: number; readonly height: number }; } @@ -608,7 +600,6 @@ describe("PreviewManager", () => { const older = makeFaviconWebContents({ id: 42 }); const newer = makeFaviconWebContents({ id: 43 }); for (const tab of [older, newer]) Object.assign(tab.webContents, { hostWebContents: host }); - // Electron names one arbitrary guest here when several share a focused window. getFocusedWebContents.mockReturnValue(newer.webContents as never); fromId.mockReturnValue(older.webContents); yield* manager.createTab("tab_older"); @@ -634,7 +625,6 @@ describe("PreviewManager", () => { expect(preventDefault).toHaveBeenCalledOnce(); expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "view.reopenClosed"); - // The person types in the older tab, so keys reaching the newer one are an agent's. Object.assign(newer.webContents, { focusedFrame: null }); const agentInput = newer.listeners.get("before-input-event")!; const agentPreventDefault = vi.fn(); @@ -770,9 +760,6 @@ describe("PreviewManager", () => { effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => withManager((manager) => Effect.gen(function* () { - // Real Electron throws on any `wc.debugger` access once the - // WebContents is destroyed, so cleanup must go through the debugger - // reference captured at attach time (electron/electron#53376). let destroyed = false; let attached = false; const debuggerOff = vi.fn(); @@ -1061,7 +1048,6 @@ describe("PreviewManager", () => { id, isDestroyed: () => destroyed, isDevToolsOpened: () => { - // Electron throws from native methods once a WebContents is destroyed. if (destroyed) throw new TypeError("Object has been destroyed"); return false; }, @@ -1173,11 +1159,7 @@ describe("PreviewManager", () => { audible = next; listeners.get("audio-state-changed")?.({ audible: next } as never); }, - /** - * Starts playing between the attach-time read and the post-attach - * reconcile, without a delivered event — the window in which - * audio-state-changed fires against a guest the tab does not own yet. - */ + /** Simulate playback starting during attach before the tab owns the guest and can receive its events. */ startPlayingAfterFirstRead: () => { audibleAfterFirstRead = true; }, @@ -1237,9 +1219,6 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_audio_window", 42); yield* Effect.yieldNow; - // audio-state-changed for this transition was dropped: it fired before - // the tab owned the guest. Without a post-attach reconcile the icon - // stays wrong until the next real transition, which may never come. expect(states.at(-1)?.audible).toBe(true); }), ), @@ -1369,7 +1348,6 @@ describe("PreviewManager", () => { fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); - // it.effect keeps TestClock fixed until explicitly advanced. const first = yield* manager.captureScreenshot("tab_1"); const second = yield* manager.captureScreenshot("tab_1"); @@ -1735,6 +1713,65 @@ describe("PreviewManager", () => { }).pipe(Effect.provide(managerLayer()), Effect.scoped), ); + effectIt.effect( + "asks the client to paint a navigated server tab while the agent captures it", + () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.DesktopBrowserHost; + const serverTab = { threadId: "thread-1", tabId: "server-tab-1" }; + const contents = Object.assign( + makeTestPreviewWebContents(async () => { + throw new Error("unused"); + }, 42), + { + isDevToolsOpened: () => false, + getUserAgent: () => "Electron", + loadURL: vi.fn(async () => undefined), + }, + ); + const screenshot = Promise.withResolvers(); + contents.debugger.sendCommand = vi.fn((method: string) => + method === "Page.captureScreenshot" ? screenshot.promise : Promise.resolve(undefined), + ) as unknown as typeof contents.debugger.sendCommand; + fromId.mockReturnValue(contents); + const manager = yield* PreviewManager.PreviewManager; + const capturing = yield* Queue.unbounded(); + yield* manager.subscribeStateChanges((_tabId, state) => + Queue.offer(capturing, state.capturing).pipe(Effect.asVoid), + ); + const attached = yield* Deferred.make(); + yield* host.events.pipe( + Stream.runForEach((line) => + new TextDecoder().decode(line).includes('"type":"attached"') + ? Deferred.succeed(attached, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* manager.createTab("tab_1", { serverTab }); + yield* manager.registerWebview("tab_1", 42); + yield* Deferred.await(attached); + // Navigating rebuilds the tab's state, which must keep the server tab it answers for. + yield* manager.navigate("tab_1", "https://example.com/next"); + yield* Queue.clear(capturing); + + yield* host.handleCommandLine( + JSON.stringify({ + type: "cdp", + ...serverTab, + message: JSON.stringify({ + id: 1, + method: "Page.captureScreenshot", + sessionId: "t3-preview-page", + }), + }), + ); + expect(yield* Queue.take(capturing)).toBe(true); + screenshot.resolve({ data: "" }); + expect(yield* Queue.take(capturing)).toBe(false); + }).pipe(Effect.provide(managerLayer()), Effect.scoped), + ); + effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index e9578c711ae40..d0221e9be0e6e 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1,10 +1,3 @@ -/** - * Desktop side of the in-app browser preview. - * - * Hosts per-tab Chromium WebContents references (the actual - * elements live in the renderer; we only attach listeners and forward state - * here). Single layer-scoped browser session partition. - */ import * as NodeZlib from "node:zlib"; import { DesktopPreviewRecordingInputSchema, @@ -109,6 +102,7 @@ export interface PreviewTabState { audioMuted: boolean; audible: boolean; controller: "human" | "agent" | "none"; + capturing: boolean; favicon?: DesktopPreviewFavicon; serverTab?: { readonly threadId: string; readonly tabId: string }; updatedAt: string; @@ -123,10 +117,7 @@ const ZOOM_EPSILON = 0.001; const RECORDING_ARM_GRACE_MS = 10_000; const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; -/** - * Cold guests can reject capturePage with UnknownVizError or never settle it. - * Bound each attempt so snapshots release control even when Chromium stalls. - */ +// Cold guests can reject capturePage with UnknownVizError or stall; bound each attempt. const CAPTURE_PAGE_RETRY_ATTEMPTS = 3; const CAPTURE_PAGE_RETRY_DELAY_MS = 120; const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000; @@ -325,22 +316,12 @@ const cropAnnotationScreenshot = ( return { dataUrl: cropped.toDataURL(), width: size.width, height: size.height, cropRect }; }; -/** - * Electron converts `toBitmap()` output to sRGB by default. Captures on P3 Macs are - * P3, so that conversion costs more per tick than the JPEG encode the fingerprint - * replaces. An invalid target color space has no Skia color space, so `readPixels` - * copies the pixels without a color transform. The fingerprint only compares frames - * with each other, so color accuracy is irrelevant. - */ +// An invalid color space skips costly P3-to-sRGB conversion; fingerprints need no color accuracy. const FINGERPRINT_BITMAP_OPTIONS: Electron.ToBitmapOptions = { colorSpace: { primaries: "invalid", transfer: "invalid", matrix: "invalid", range: "invalid" }, }; -/** - * Identity of a captured frame: cheap enough to compute every tick, unlike a JPEG - * encode. Null when the pixels could not be read (Electron reports a failed - * `readPixels` as an empty buffer), so the caller treats the frame as changed. - */ +/** Empty readPixels results have no fingerprint and must be treated as changed frames. */ const fingerprintCapturedFrame = (image: Electron.NativeImage, size: Electron.Size) => { let bitmap: Buffer; try { @@ -421,10 +402,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 +421,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 +432,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 +441,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 +552,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 +857,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 +874,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 +885,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 +1047,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 +1146,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 +1196,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 +1235,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 +1253,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 +1457,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 +1466,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" })); @@ -1716,6 +1653,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; return [ @@ -1789,6 +1727,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function audioMuted: false, audible: false, controller: "none", + capturing: false, updatedAt, }; yield* emit(tabId, closed); @@ -1872,15 +1811,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 +1869,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 +1906,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Called when a guest attaches to the window, before its first document paints. A tab opened - // straight to a URL often paints before the renderer gets to registerWebview, which would - // leave that page on the transparent base. + // URL tabs can paint before registerWebview; set the background at attach time. const prepareWebview = Effect.fn("PreviewManager.prepareWebview")(function* ( wc: Electron.WebContents, ) { @@ -2016,14 +1945,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, colorScheme: current?.colorScheme ?? "system", - // Mute is user intent, and the old document keeps playing until loadURL - // actually replaces it, so clearing audibility here would drop the - // speaker with no transition left to restore it. Chromium reports the - // change when it happens. + // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, audible: current?.audible ?? false, controller: current?.controller ?? "none", + capturing: current?.capturing ?? false, ...(current?.favicon ? { favicon: current.favicon } : {}), + ...(current?.serverTab ? { serverTab: current.serverTab } : {}), updatedAt, }; return [ @@ -2177,8 +2105,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 +2197,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 +2216,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 +2224,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 +2272,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); - // Emulated media lives on the CDP debugger session, not the WebContents, so - // it is lost whenever the session detaches (webview swap, DevTools - // open/close) and must be re-applied after every (re)attach. + // Media emulation belongs to the debugger session and must be restored after every reattach. const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( tabId: string, wc: Electron.WebContents, @@ -2386,7 +2304,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } if (afterAttach.serverTab) { - yield* listenForAgentPointers; + yield* listenToBrowserHost; browserHost.attach(afterAttach.serverTab, { webContents: wc, debugger: control.debugger }); } if (afterAttach.colorScheme !== "system") { @@ -2440,8 +2358,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 +2374,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 +2383,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 +2495,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 +2850,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 +2985,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 +3012,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 +3025,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); - // Installed once per session: answers the renderer's `getDisplayMedia()` with the tab that - // `startRecording` armed, and denies anything else so pages cannot capture on their own. + // Only serve the tab armed by startRecording; pages must not capture independently. const installDisplayMediaRequestHandler = (session: Session) => { if (displayMediaHandlerSessions.has(session)) return; displayMediaHandlerSessions.add(session); @@ -3339,14 +3244,33 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); - let pointersStarted = false; - const listenForAgentPointers = Effect.suspend(() => { - if (pointersStarted) return Effect.void; - pointersStarted = true; - return browserHost.pointers.pipe( - Stream.runForEach(emitAgentPointer), - Effect.forkIn(parentScope), - Effect.asVoid, + const updateServerTabCapturing = Effect.fn("PreviewManager.updateServerTabCapturing")( + function* (painting: { + readonly key: { readonly threadId: string; readonly tabId: string }; + readonly active: boolean; + }) { + const tab = [...(yield* SynchronizedRef.get(tabsRef)).values()].find( + (candidate) => + candidate.serverTab?.threadId === painting.key.threadId && + candidate.serverTab.tabId === painting.key.tabId, + ); + if (tab && tab.capturing !== painting.active) { + yield* update(tab.tabId, { capturing: painting.active }); + } + }, + ); + let browserHostStarted = false; + // Subscribes before the attach that follows, so no event falls between the two. + const listenToBrowserHost = Effect.suspend(() => { + if (browserHostStarted) return Effect.void; + browserHostStarted = true; + const listen = Effect.forkIn(parentScope, { startImmediately: true }); + return Effect.all( + [ + browserHost.pointers.pipe(Stream.runForEach(emitAgentPointer), listen), + browserHost.painting.pipe(Stream.runForEach(updateServerTabCapturing), listen), + ], + { discard: true }, ); }); @@ -3679,7 +3603,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.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 832a774577149..0135225f90c48 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -90,6 +91,37 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect("records one bounds operation span with its input attributes", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true); + }), + ).pipe(Effect.provideService(Tracer.Tracer, tracer)); + const operationSpans = spans.filter( + (span) => span.name === "desktop.settings.setMainWindowBounds", + ); + assert.lengthOf(operationSpans, 1); + assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), { + x: 10, + y: 20, + width: 900, + height: 600, + isMaximized: true, + }); + assert.equal(operationSpans[0]!.status._tag, "Ended"); + }), + ); + it.effect( "persists disabling and re-enabling local execution without clearing backend settings", () => diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1c131681295fa..5767057b4a35d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -37,8 +37,7 @@ export interface DesktopSettings { readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; readonly wslDistro: string | null; - // Changing this requires a desktop restart because the pool's primary spec is - // chosen once at layer init. + // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; } @@ -466,7 +465,6 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp }); /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; @@ -523,48 +521,56 @@ export const make = Effect.gen(function* () { ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), - setMainWindowBounds: (bounds, isMaximized) => - persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)).pipe( - Effect.withSpan("desktop.settings.setMainWindowBounds", { - attributes: { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - isMaximized, - }, - }), - ), - setServerExposureMode: (mode) => - persist((settings) => setServerExposureMode(settings, mode)).pipe( - Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), - ), - setTailscaleServe: (input) => - persist((settings) => setTailscaleServe(settings, input)).pipe( - Effect.withSpan("desktop.settings.setTailscaleServe", { attributes: input }), - ), - setUpdateChannel: (channel) => - persist((settings) => setUpdateChannel(settings, channel)).pipe( - Effect.withSpan("desktop.settings.setUpdateChannel", { attributes: { channel } }), - ), - setWslBackendEnabled: (enabled) => - persist((settings) => setWslBackendEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslBackendEnabled", { attributes: { enabled } }), - ), - setWslDistro: (distro) => - persist((settings) => setWslDistro(settings, distro)).pipe( - Effect.withSpan("desktop.settings.setWslDistro", { - attributes: { distro: distro ?? null }, - }), - ), - setWslOnly: (enabled) => - persist((settings) => setWslOnly(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), - ), - setLocalEnvironmentEnabled: (enabled) => - persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( - Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), - ), + setMainWindowBounds: Effect.fn("desktop.settings.setMainWindowBounds")( + function* (bounds, isMaximized) { + yield* Effect.annotateCurrentSpan({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + isMaximized, + }); + + return yield* persist((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("desktop.settings.setServerExposureMode")(function* (mode) { + yield* Effect.annotateCurrentSpan({ mode }); + + return yield* persist((settings) => setServerExposureMode(settings, mode)); + }), + setTailscaleServe: Effect.fn("desktop.settings.setTailscaleServe")(function* (input) { + yield* Effect.annotateCurrentSpan(input); + + return yield* persist((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { + yield* Effect.annotateCurrentSpan({ channel }); + + return yield* persist((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("desktop.settings.setWslBackendEnabled")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslBackendEnabled(settings, enabled)); + }), + setWslDistro: Effect.fn("desktop.settings.setWslDistro")(function* (distro) { + yield* Effect.annotateCurrentSpan({ distro: distro ?? null }); + + return yield* persist((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("desktop.settings.setWslOnly")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( + function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -596,18 +602,38 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET return DesktopAppSettings.of({ get: SynchronizedRef.get(settingsRef), load: SynchronizedRef.get(settingsRef), - setMainWindowBounds: (bounds, isMaximized) => - update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), - setServerExposureMode: (mode) => - update((settings) => setServerExposureMode(settings, mode)), - setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), - setUpdateChannel: (channel) => update((settings) => setUpdateChannel(settings, channel)), - setWslBackendEnabled: (enabled) => - update((settings) => setWslBackendEnabled(settings, enabled)), - setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), - setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), - setLocalEnvironmentEnabled: (enabled) => - update((settings) => setLocalEnvironmentEnabled(settings, enabled)), + setMainWindowBounds: Effect.fn("DesktopAppSettings.setMainWindowBounds")( + function (bounds, isMaximized) { + return update((settings) => setMainWindowBounds(settings, bounds, isMaximized)); + }, + ), + setServerExposureMode: Effect.fn("DesktopAppSettings.setServerExposureMode")( + function (mode) { + return update((settings) => setServerExposureMode(settings, mode)); + }, + ), + setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { + return update((settings) => setTailscaleServe(settings, input)); + }), + setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { + return update((settings) => setUpdateChannel(settings, channel)); + }), + setWslBackendEnabled: Effect.fn("DesktopAppSettings.setWslBackendEnabled")( + function (enabled) { + return update((settings) => setWslBackendEnabled(settings, enabled)); + }, + ), + setWslDistro: Effect.fn("DesktopAppSettings.setWslDistro")(function (distro) { + return update((settings) => setWslDistro(settings, distro)); + }), + setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { + return update((settings) => setWslOnly(settings, enabled)); + }), + setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( + function (enabled) { + return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); + }, + ), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 7c65c35cf0439..0836db6678226 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -194,8 +194,8 @@ export const make = Effect.gen(function* () { get: readClientSettings(fileSystem, environment.clientSettingsPath).pipe( Effect.withSpan("desktop.clientSettings.get"), ), - set: (settings) => - crypto.randomUUIDv4().pipe( + set: Effect.fn("desktop.clientSettings.set")(function (settings) { + return crypto.randomUUIDv4().pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), Effect.mapError( (cause) => @@ -214,8 +214,8 @@ export const make = Effect.gen(function* () { suffix, }), ), - Effect.withSpan("desktop.clientSettings.set"), - ), + ); + }), }); }); @@ -228,7 +228,9 @@ export const layerTest = (initialSettings: Option.Option = Optio const settingsRef = yield* Ref.make(initialSettings); return DesktopClientSettings.of({ get: Ref.get(settingsRef), - set: (settings) => Ref.set(settingsRef, Option.some(settings)), + set: Effect.fn("DesktopClientSettings.set")(function (settings) { + return Ref.set(settingsRef, Option.some(settings)); + }), }); }), ); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 248fb9b6f8482..cccc0585024af 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -221,7 +221,7 @@ function decodeSecretBytes( ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 3fdec7932366b..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..8532dee0038a0 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -798,7 +798,6 @@ it.effect.each(["darwin", "win32"] as const)( for (const finish of heldFlights) finish(); yield* Fiber.join(landing); - // Landing releases the guard even before the first capture is acknowledged. yield* Effect.promise(fixture.trigger); assert.equal(fixture.state.snapshots, 2); assert.lengthOf(fixture.readyIds, 2); @@ -1049,9 +1048,15 @@ it.effect("matches Windows accessibility windows on a scaled display", () => { ); }); -it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( - "persists %s text without inventing AT-SPI screen coordinates", - (backend) => { +it.effect.each([ + { backend: "gnome", screenshot: "PNG" }, + { backend: "niri", screenshot: "PNG" }, + { backend: "kde", screenshot: "PNG" }, + { backend: "hyprland", screenshot: "PNG" }, + { backend: "gnome", screenshot: "invalid" }, +] as const)( + "persists $backend accessibility text with a $screenshot screenshot", + ({ backend, screenshot }) => { vi.stubEnv("XDG_SESSION_TYPE", "wayland"); flashWindows.length = 0; const window = { @@ -1068,7 +1073,16 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( ...(backend === "niri" ? { accessibilityBoundsReliable: false } : {}), ...(backend === "kde" ? { clientBounds: { x: 479, y: 371, width: 700, height: 520 } } : {}), }; - linuxCaptureMock.mockResolvedValueOnce({ png: Buffer.from([1, 2, 3]), window }); + // The captured pixels differ from the compositor's logical window bounds. + const imageSize = screenshot === "PNG" ? { width: 1400, height: 1040 } : window.bounds; + const png = + screenshot === "PNG" + ? Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAABXgAAAQQAQAAAABwoA4ZAAAAyElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgYyx4AAepc/c0AAAAASUVORK5CYII=", + "base64", + ) + : Buffer.from([1, 2, 3]); + linuxCaptureMock.mockResolvedValueOnce({ png, window }); activeWindowMock.mockClear(); getSourcesMock.mockClear(); accessibilityByPidMock.mockReset().mockResolvedValue({ @@ -1108,7 +1122,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( assert.deepInclude(saved.source.accessibility, { format: "element-tree", coordinateSpace: "captured-image", - imageSize: { width: 700, height: window.bounds.height }, + imageSize: { width: imageSize.width, height: imageSize.height }, truncated: false, }); assert.deepInclude( @@ -1118,7 +1132,7 @@ it.effect.each(["gnome", "niri", "kde", "hyprland"] as const)( { role: "window", name: "Editor", - bounds: { x: 0, y: 0, width: 700, height: window.bounds.height }, + bounds: { x: 0, y: 0, width: imageSize.width, height: imageSize.height }, state: { active: true }, }, ); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 164c97b4f22fc..30edacb073c36 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -90,6 +90,7 @@ import { snapShotShortcutSystemConflict, } from "./snapShot.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; const MAX_CAPTURE_WIDTH = 2_560; const MAX_CAPTURE_HEIGHT = 1_600; @@ -211,7 +212,6 @@ export class DesktopSnapShotSetupError extends Schema.TaggedError 0 && size.height > 0) return size; - } catch {} + // Capture backends already validate or encode the PNG. Reading its header + // avoids decoding every pixel again just to size accessibility coordinates. + const size = readImageDimensions(png); + if (size) return size; return { width: Math.max(1, Math.round(fallback.width)), height: Math.max(1, Math.round(fallback.height)), @@ -903,8 +899,7 @@ export const make = Effect.gen(function* () { const imageTempPath = path.join(captureDirectory, `${id}.tmp.png`); return yield* Effect.gen(function* () { - // Retire feedback before reading screen pixels, so a rapid capture cannot - // photograph the previous capture's overlay. + // Retire feedback before capture so rapid captures cannot photograph the previous overlay. closeLinuxFeedback(); flash.dispose(); transition.dispose(); @@ -1033,8 +1028,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.snapShot.capture")); const captureFromShortcut = Effect.gen(function* () { - // Repeated shortcuts must not tear down a moving capture and start another - // renderer. Drop them while the flight runs, just as during pixel acquisition. + // Repeated shortcuts must not replace a capture during its flight. if (shortcutSuppressed || transition.isAnimating) return; shortcutVerified = true; const now = yield* Clock.currentTimeNanos; @@ -1140,9 +1134,7 @@ export const make = Effect.gen(function* () { transition.dispose(); closeLinuxFeedback(); } - // Every client-settings save lands here. Only the fields that decide which - // shortcut listener runs may tear it down; a font-size change must not - // uninstall a global keyboard hook or drop an approved portal session. + // Unrelated settings saves must not uninstall the keyboard hook or drop an approved portal session. const shortcutInputsChanged = settings.snapShotEnabled !== previousSettings.snapShotEnabled || settings.snapShotIncludeAccessibility !== previousSettings.snapShotIncludeAccessibility || @@ -1726,24 +1718,28 @@ export const make = Effect.gen(function* () { ), ), ), - setAnimationDestination: (id, destination) => - Effect.promise(async () => { - if (linuxFeedback?.id === id && destination.relativeFrame) { - await linuxFeedback.feedback - .animateTo(destination.relativeFrame) - .catch(() => closeLinuxFeedback(id)); - return; - } - transition.animateTo(id, destination); - await transition.waitForLanding(id); - }), - dismissAnimation: (id) => - Effect.sync(() => { + setAnimationDestination: Effect.fn("DesktopSnapShot.setAnimationDestination")( + function (id, destination) { + return Effect.promise(async () => { + if (linuxFeedback?.id === id && destination.relativeFrame) { + await linuxFeedback.feedback + .animateTo(destination.relativeFrame) + .catch(() => closeLinuxFeedback(id)); + return; + } + transition.animateTo(id, destination); + await transition.waitForLanding(id); + }); + }, + ), + dismissAnimation: Effect.fn("DesktopSnapShot.dismissAnimation")(function (id) { + return Effect.sync(() => { closeLinuxFeedback(id); transition.dismiss(id); - }), - acknowledge: (id) => - Effect.promise(async () => { + }); + }), + acknowledge: Effect.fn("DesktopSnapShot.acknowledge")(function (id) { + return Effect.promise(async () => { await completeLinuxFeedback(id); await transition.complete(id); }).pipe( @@ -1759,7 +1755,8 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new DesktopSnapShotError({ operation: "acknowledge", captureId: id, cause }), ), - ), + ); + }), }); }); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index bfd6b2984147d..50d9e700ee51e 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -182,8 +182,7 @@ export class KdeCaptureSetup { } // KService must see the new desktop entry before KWin checks the executable. await run("kbuildsycoca6", ["--noincremental"]); - // AppImages change XDG search paths, and KService caches are also locale-specific. - // Refresh Plasma's cache in its session environment, not just the app's cache. + // AppImage search paths and locale affect KService caches; refresh in Plasma's session environment. await run("systemd-run", [ "--user", "--quiet", diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index ad7b2e03c4954..117ffa652566b 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -11,8 +11,7 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record { send(event, { ScreenshotCaptured: { path: "/another-app.png" } }); send(event, { ScreenshotCaptured: { path: screenshot.path } }); } - // The event may beat the command reply. send(socket, { Ok: "Handled" }); } else send(socket, { Ok: "Handled" }); }; diff --git a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts index 374745c96e60c..f0ac7b9c1bba2 100644 --- a/apps/desktop/src/snapShot/RegionSnapShotWorker.ts +++ b/apps/desktop/src/snapShot/RegionSnapShotWorker.ts @@ -1,5 +1,4 @@ -// Runs in a forked Node-mode child so the native screenshot call cannot crash -// or stall the main process. +// Isolate native screenshot crashes and stalls in a Node-mode child. import type { RegionSnapShotRequest, RegionSnapShotResult } from "./RegionSnapShot.ts"; process.once("disconnect", () => process.exit(0)); diff --git a/apps/desktop/src/snapShot/SnapShotAccessibility.ts b/apps/desktop/src/snapShot/SnapShotAccessibility.ts index 3361aafc3c78a..9021c8904e3fe 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibility.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibility.ts @@ -172,8 +172,7 @@ async function readCapturedWindowAccessibility( .catch(() => { progress.flatComplete = true; }); - // A decorated screenshot contains more than the accessibility client area. Keep its - // frame origin/scale so element coordinates include the actual decoration offset. + // Preserve the screenshot frame origin and scale so accessibility coordinates include decorations. const sourceBounds = matchMode === "wayland" && active.clientBounds ? active.bounds diff --git a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts index dd1474b885052..542e3f13c08dc 100644 --- a/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts +++ b/apps/desktop/src/snapShot/SnapShotAccessibilityProcess.ts @@ -49,8 +49,7 @@ export function startSnapShotAccessibilityProcess(workerPath: string): Accessibi let worker: NodeChildProcess.ChildProcess; try { worker = NodeChildProcess.fork(workerPath, ["read"], { - // Electron defaults to its Helper executable on macOS, which does not - // share the main app's Accessibility grant checked during setup. + // Electron's default macOS Helper executable lacks the main app's Accessibility grant. execPath: process.execPath, env: { ...process.env, diff --git a/apps/desktop/src/snapShot/SnapShotTransition.ts b/apps/desktop/src/snapShot/SnapShotTransition.ts index 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..f6c9ee43d7580 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -125,7 +125,7 @@ const makePasswordPrompt = ( prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)), }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; @@ -133,32 +133,26 @@ export const make = Effect.gen(function* () { const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ - discoverHosts: (input) => - discoverDesktopSshHostsEffect(input).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.discoverHosts"), - ), - resolveHost: (alias) => - resolveSshTarget(alias.trim()).pipe( - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.resolveHost"), - ), - ensureEnvironment: (target, ensureOptions) => - manager - .ensureEnvironment(target, ensureOptions) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.ensureEnvironment"), - ), - disconnectEnvironment: (target) => - manager - .disconnectEnvironment(target) - .pipe( - Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), - Effect.provide(runtimeContext), - Effect.withSpan("desktop.ssh.disconnectEnvironment"), - ), + discoverHosts: Effect.fn("desktop.ssh.discoverHosts")(function* (input) { + return yield* discoverDesktopSshHostsEffect(input); + }, Effect.provide(runtimeContext)), + resolveHost: Effect.fn("desktop.ssh.resolveHost")(function* (alias) { + return yield* resolveSshTarget(alias.trim()); + }, Effect.provide(runtimeContext)), + ensureEnvironment: Effect.fn("desktop.ssh.ensureEnvironment")( + function* (target, ensureOptions) { + return yield* manager.ensureEnvironment(target, ensureOptions); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), + disconnectEnvironment: Effect.fn("desktop.ssh.disconnectEnvironment")( + function* (target) { + return yield* manager.disconnectEnvironment(target); + }, + Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt), + Effect.provide(runtimeContext), + ), }); }); diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 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 ae3c2588fab2b..3d6556ffae9a3 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,11 +737,7 @@ export const make = Effect.gen(function* () { yield* writeUpdateRestartMarker; const { downloadedVersion } = yield* Ref.get(updateStateRef); if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion); - // Stop every backend in the pool, not just the primary. With parallel - // WSL + Windows backends, leaving the WSL instance up means - // quitAndInstall's app.quit() exits before the pool's scope cascade - // has a chance to run its stop finalizer, so the WSL child gets - // hard-killed by the OS instead of receiving SIGTERM + grace. + // Stop pooled backends before quitAndInstall can outrun their scope finalizers. const instances = yield* pool.list; // stop clears desiredRunning, so retain the recovery set beforehand. const runningInstances = yield* Effect.filter(instances, (instance) => @@ -1168,7 +1159,9 @@ export const make = Effect.gen(function* () { install: installWithExpectedVersion().pipe( Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), ), - installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), + installPrepared: Effect.fn("DesktopUpdates.installPrepared")(function (expectedVersion) { + return installWithExpectedVersion(expectedVersion); + }), }); }); diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 008a83eab03db..028e42fa6e3b7 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -81,8 +81,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("drops releases from other trains before grouping on nightly", () => { - // electron-updater's full changelog is "every version above the running - // one", and preview sorts above nightly, so the preview cuts come first. const releaseNotes = [ { version: "0.0.41-preview.20260914.1683", note: "- Maintainer test build" }, { version: "0.0.41-preview.20260913.1669", note: "- Maintainer test build" }, diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index ef6d217299d09..69d710ff7b269 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -30,8 +30,6 @@ const HTML_ENTITY_REPLACEMENTS: Readonly> = { }; function decodeCodePoint(codePoint: number, entity: string): string { - // String.fromCodePoint throws RangeError outside the valid Unicode range, and - // Number.isFinite alone lets oversized values (e.g. �) through. if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return `&${entity};`; } @@ -124,12 +122,7 @@ interface NormalizedDesktopUpdateReleaseNotes { readonly omittedReleaseCount: number; } -/** - * With `fullChangelog` on (nightly), electron-updater collects every GitHub - * release whose version is semver-greater than the running one, whatever train - * it belongs to; a maintainers' `-preview.` cut sorts above every `-nightly.` - * of the same base version and would lead the list. - */ +/** fullChangelog includes all greater semvers, so preview cuts sort above nightlies of the same base version. */ export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts index f7c65b35820cc..1d9588315559c 100644 --- a/apps/desktop/src/updates/remoteUpdateFlow.ts +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -11,12 +11,7 @@ export type RemoteDesktopUpdateStep = readonly reason?: string; }; -/** - * The caps are what stop a check -> up-to-date -> check loop and endless - * download retries; they are counts rather than booleans because a state event - * raced by the local 4-minute poller can re-show an already-handled status - * once. - */ +/** Bound retries with counts: background poller races can repeat an already-handled status once. */ export interface RemoteDesktopUpdateAttempts { readonly checks: number; readonly downloads: number; @@ -46,9 +41,7 @@ export function nextRemoteDesktopUpdateStep( reason: disabledReason ?? "Automatic updates are disabled on this machine.", }; } - // An install of this version already failed to apply here. Installing it - // again from afar would stop the app's threads for nothing, so only the user - // on this machine retries it. + // Reinstalling a failed version disrupts threads without updating; retries require the local user. if ( state.notAppliedVersion !== undefined && (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion @@ -59,11 +52,7 @@ export function nextRemoteDesktopUpdateStep( reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`, }; } - // Mirror installDownloadedUpdate's own admission rule exactly, so the run - // never reports an install that the updater then refuses. A download - // survives an unrelated background updater error (errorContext null) and - // a previous failed install (errorContext "install"); it does not survive - // a check or download error, which fall through to the error branch. + // Match install admission, including retained downloads after unrelated background or earlier install errors. if (isInstallableDesktopUpdateState(state)) { return { action: "install" }; } @@ -80,9 +69,7 @@ export function nextRemoteDesktopUpdateStep( } return { action: "download" }; } - // "up-to-date" and "error" are retained from earlier/background checks, - // so before this run has issued its own check they are stale, not - // terminal: the whole point of a remote request is to look again. + // Background up-to-date/error states are stale until this request has issued its own check. if (state.status === "up-to-date") { if (attempts.checks === 0) { return { action: "check" }; @@ -109,11 +96,7 @@ export function nextRemoteDesktopUpdateStep( return { action: "check" }; } -/** - * Updater messages are plain strings and may be blank; a blank reason would - * fail encoding and silently drop the terminal report, leaving the server to - * wait for its timeout. - */ +/** Blank reasons fail encoding and drop the terminal report, forcing the server to time out. */ export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { const trimmed = reason?.trim(); return trimmed ? trimmed : undefined; diff --git a/apps/desktop/src/updates/updateChannels.ts b/apps/desktop/src/updates/updateChannels.ts index 1b1a1ec1efa99..d79a374a668a5 100644 --- a/apps/desktop/src/updates/updateChannels.ts +++ b/apps/desktop/src/updates/updateChannels.ts @@ -1,10 +1,7 @@ import type { DesktopUpdateChannel } from "@t3tools/contracts"; const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/; -// They share nightly's branding but are packaged without an update feed (see -// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the channel -// a preview install reports is cosmetic: it never checks for updates and no -// updater feed ever lists a preview release. +// Preview builds share nightly branding but ship without an update feed. const PRERELEASE_VERSION_PATTERN = /^[^-+]+-(?:nightly|preview)\.\d{8}\.\d+$/; export function isNightlyDesktopVersion(version: string): boolean { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index aeaca83507093..7c70656c1ac4c 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -106,14 +106,13 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { return; } - // The result dialogs need no main window. Opening one before the backend - // answers strands the renderer on a failed auth bootstrap. + // Opening a window before backend confirmation can strand it on failed auth bootstrap. const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.activate; yield* checkForUpdatesFromMenu; }).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -143,10 +142,7 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; - // Chromium already pastes as plain text for this chord, so the accelerator - // needs nothing from the menu: the composer and the terminal each arm - // themselves from the same keydown. Routing it through the renderer anyway - // lands a second, injected paste and doubles the text. + // Chromium and the renderer already handle this chord; injecting another paste duplicates text. const pasteAsTextClick = ( _item: Electron.MenuItem, _window: Electron.BaseWindow | undefined, @@ -238,11 +234,7 @@ export const make = Effect.gen(function* () { { label: "View", submenu: [ - /* - Not the reload, DevTools or zoom roles: those act on the focused - webContents, so with a browser page focused they reload or zoom - the guest page and the app UI appears stuck. - */ + /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */ { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") }, { label: "Force Reload", @@ -274,8 +266,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 dbdde033cae76..8b6ab7c7d8a5e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -585,8 +585,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..36939729682ba 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 810143c75a796..44ea627ceab72 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -77,11 +77,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". @@ -181,10 +178,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 ( @@ -272,8 +266,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), @@ -345,8 +338,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); @@ -492,6 +484,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..aaf64bdcecc36 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( @@ -278,13 +265,14 @@ const launchBrowser = Effect.fnUntraced(function* (input: { textEncoder.encode('"}}\0'), ]).pipe(Effect.asVoid); - const receive = (raw: string) => { + // Incoming messages share the session span; frame acknowledgements are frequent. + const receive = Effect.fnUntraced(function* (raw: string) { const message = Option.getOrUndefined(decodeCdpMessage(raw)); if (message?.id !== undefined) { const waiter = pending.get(message.id); pending.delete(message.id); - if (!waiter) return Effect.void; - return message.error + if (!waiter) return yield* Effect.void; + return yield* message.error ? Deferred.fail( waiter.reply, new HtmlRenderBrowserError({ @@ -296,25 +284,24 @@ const launchBrowser = Effect.fnUntraced(function* (input: { } const sessionId = message?.sessionId; const page = sessionId === undefined ? undefined : pages.get(sessionId); - if (sessionId === undefined || !page || !message?.method) return Effect.void; + if (sessionId === undefined || !page || !message?.method) return yield* Effect.void; if (message.method === "Page.loadEventFired") { - return page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void; + return yield* page.loaded ? Deferred.succeed(page.loaded, undefined) : Effect.void; } if (message.method === "Fetch.requestPaused") { const paused = Option.getOrUndefined(decodeRequestPaused(message.params)); - if (!paused) return Effect.void; + if (!paused) return yield* Effect.void; const body = page.body; const url = paused.request.url.split("#", 1)[0]!; if (url === PAGE_URL && body !== undefined) { - return fulfillPage(sessionId, paused.requestId, body); + return yield* 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 && !url.startsWith(`${PAGE_ORIGIN}/`); - return otherSiteFrame + return yield* otherSiteFrame ? post("Fetch.continueRequest", { requestId: paused.requestId }, sessionId) : post( "Fetch.failRequest", @@ -323,10 +310,10 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); } const consoleMessage = consoleMessageFromEvent(message.method, message.params); - if (!consoleMessage) return Effect.void; + if (!consoleMessage) return yield* Effect.void; if (page.consoleMessages.length >= MAX_CONSOLE_MESSAGES) { page.omittedConsoleMessages += 1; - return Effect.void; + return yield* Effect.void; } const text = consoleMessage.text.replaceAll(PAGE_URL, PAGE_NAME); page.consoleMessages.push({ @@ -334,11 +321,10 @@ const launchBrowser = Effect.fnUntraced(function* (input: { text: text.length > MAX_CONSOLE_TEXT_CHARS ? `${text.slice(0, MAX_CONSOLE_TEXT_CHARS)}…` : text, }); - return Effect.void; - }; + return yield* 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 +353,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 +399,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 +422,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 +439,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 +541,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 +550,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 +568,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 +587,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..0319bbb55c03d 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 })), @@ -632,9 +630,22 @@ describe("video asset byte ranges", () => { const image = HttpServerResponse.toWeb( yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"), ); - expect(image.status).toBe(200); - expect(image.headers.has("accept-ranges")).toBe(false); - expect(yield* Effect.promise(() => image.text())).toBe("0123456789"); + expect(image.status).toBe(206); + expect(image.headers.get("accept-ranges")).toBe("bytes"); + expect(yield* Effect.promise(() => image.text())).toBe("01"); + for (const mimeType of ["text/plain", "text/csv", "application/octet-stream"]) { + for (const [range, expected, status] of [ + ["bytes=2-5", "2345", 206], + ["bytes=-3", "789", 206], + ["bytes=10-", "", 416], + ] as const) { + const result = HttpServerResponse.toWeb( + yield* assetFileResponse({ path: file, mimeType }, range), + ); + expect(result.status).toBe(status); + expect(yield* Effect.promise(() => result.text())).toBe(expected); + } + } }).pipe(Effect.provide(layerFileResponse)), ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8871a5df34517..af763b73f7055 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -31,7 +31,7 @@ import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; -import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; +import { sourceControlMediaResponse } from "./assets/sourceControlMediaResponse.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, @@ -58,15 +58,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) && @@ -196,15 +193,13 @@ 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; let offset = 0n; let bytesToRead: bigint | undefined; - if (isMedia) { + { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { @@ -259,8 +254,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 ? { @@ -333,8 +327,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, @@ -391,9 +384,7 @@ export const layerOtlpTracesProxyRoute = HttpRouter.add( const UNTRACED_REQUEST_PATHS: ReadonlySet = new Set([OTLP_TRACES_PROXY_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); @@ -432,10 +423,15 @@ export const layerAssetRoute = HttpRouter.add( headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" }, }); } - if (asset.kind === "github-media") { - return yield* githubMediaResponse(asset, request.headers).pipe( + if (asset.kind === "source-control-media") { + return yield* sourceControlMediaResponse(asset, request.headers).pipe( Effect.tapError((cause) => - Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }), + // The URL can carry a token in its query string, and the error keeps the request it + // failed on; the host and the failure kind are enough to diagnose. + Effect.logWarning("Failed to fetch source control media.", { + host: URL.canParse(asset.url) ? new URL(asset.url).host : undefined, + reason: cause.reason._tag, + }), ), Effect.orElseSucceed(() => HttpServerResponse.empty({ @@ -664,8 +660,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 f42d309c49b0c..24f9b276af8ba 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -571,8 +571,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); @@ -617,8 +616,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..f3f331f04ac34 100644 --- a/apps/server/src/mcp/AcpMcpStdioBridge.ts +++ b/apps/server/src/mcp/AcpMcpStdioBridge.ts @@ -1,19 +1,16 @@ -// 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"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; import * as Fiber from "effect/Fiber"; 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 +250,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; @@ -280,13 +274,11 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. line: string, ): Effect.fn.Return { if (line.trim().length === 0) return; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { + const parsed = yield* Effect.try((): unknown => JSON.parse(line)).pipe(Effect.result); + if (Result.isFailure(parsed)) { return yield* respondWithError(null, "Parse error", -32700); } - const envelope = asEnvelope(parsed); + const envelope = asEnvelope(parsed.success); if (envelope === null) return; const isResponse = envelope.id !== undefined && ("result" in envelope || "error" in envelope); const isSessionHandshake = @@ -321,16 +313,13 @@ export function runAcpMcpStdioBridge(options: AcpMcpStdioBridgeOptions): Effect. const reader = NodeReadline.createInterface({ input: options.input }); yield* Stream.runForEach(Stream.fromAsyncIterable(reader, bridgeError), dispatch).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); yield* Fiber.awaitAll(running); }); } -/** - * 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/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 967f9e138c1b2..ca96fd582f328 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; @@ -102,7 +101,7 @@ const readiness = { agentDevice: null, }; const layerProjectsMock = Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.none()), + getById: () => Effect.succeedNone, }); const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 85ccc3ab926e4..8b73483380e2c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -132,7 +132,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, @@ -365,7 +364,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); @@ -422,6 +420,15 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it("keeps cross-tool routing out of the delegate_task description", () => { + const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; + expect(description).toContain("Start one child agent with only `task`"); + // Review rounds, follow-ups, and polling are routed in the system text. + expect(description).not.toContain("t3_thread_send"); + expect(description).not.toContain("Do not poll"); + expect(description).not.toContain("Prefer native"); +}); + it.effect( "registers the pull request toolkit and surfaces a missing capability as a tool error", () => @@ -438,7 +445,8 @@ it.effect( const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); - expect(linkTool?.tool.description).toContain("Register every pull request you open"); + expect(linkTool?.tool.description).toContain("Link a PR to this thread"); + expect(linkTool?.tool.description).toContain("settles the thread on merge"); const denied = yield* server .callTool({ name: "list_thread_pull_requests", arguments: {} }) @@ -533,7 +541,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"]), @@ -809,3 +816,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 5c6c4fece44bb..89eb852d1f4f3 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -78,11 +78,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; @@ -189,13 +185,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; @@ -280,8 +270,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", @@ -330,7 +319,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 ? [] @@ -400,16 +388,10 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); -/** - * Tool input schemas are advertised through each schema's JSON codec, which - * offers `null` for every `Schema.optional` field, but Toolkit decodes with the - * schema itself and rejects that `null`. Decoding through the same codec makes - * a tool accept what it advertises. - */ +/** Decode with the advertised JSON codec, which permits null for optional fields that the raw schema rejects. */ 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)), @@ -418,7 +400,9 @@ const decodeAsAdvertised = >( const isPreviewAutomationError = Schema.is(PreviewAutomationError); -const previewSnapshotFailure = (cause: Cause.Cause) => { +const previewSnapshotFailure = Effect.fn("previewSnapshotFailure")(function ( + cause: Cause.Cause, +) { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); } @@ -431,8 +415,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, @@ -452,7 +435,7 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { errorTag, failureCount: failures.length, }).pipe(Effect.as(result)); -}; +}); const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; @@ -508,8 +491,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; - // Images stay out of tool history unless asked for: providers replay them on every - // later request, and some reject inline images outright. + // Providers replay images on later requests, and some reject them; include images only when requested. const includeImage = payload?.includeImage === true; if (screenshotPath !== undefined && !includeImage) { const saved = { @@ -581,11 +563,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) => { @@ -815,14 +793,7 @@ const handoffTargetIds = ( return typeof threadId === "string" ? [threadId] : []; }; -/** - * Runs a tool registration against a server that records one anonymous - * `mcp.tool.invoked` event per call: 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) { @@ -882,6 +853,11 @@ const withToolAnalytics = result: McpSchema.CallToolResult | undefined, durationMs: number, ) { + const handoff = HANDOFF_TOOLS.has(tool); + const outcome = toolOutcome(result); + // Agents call status, read, and preview tools in tight loops, so an + // event per successful call floods analytics. Only failures count there. + if (!handoff && outcome.outcome === "ok") return; // Client credentials (no calling thread) report an unknown caller. const invocation = Option.getOrUndefined( yield* Effect.serviceOption(McpInvocationContext.McpInvocationContext), @@ -889,7 +865,6 @@ const withToolAnalytics = const providers = Option.isSome(registry) ? yield* registry.value.getProviders : []; // Only handoff tools pay for thread lookups; the rest report the // caller's provider from the credential. - const handoff = HANDOFF_TOOLS.has(tool); const caller = handoff && invocation !== undefined ? ((yield* shellOf(invocation.threadId)) ?? undefined) @@ -917,7 +892,7 @@ const withToolAnalytics = ), }), targets: targets.filter((thread) => thread !== null), - outcome: toolOutcome(result), + outcome, ...(handoff ? { settings: handoffSettings(tool, args, result?.structuredContent) } : {}), @@ -930,8 +905,8 @@ const withToolAnalytics = ); const recordingServer = McpServer.McpServer.of({ ...server, - addTool: (options) => - server.addTool({ + addTool: Effect.fn("McpServer.addTool")(function (options) { + return server.addTool({ ...options, handle: /* Request instrumentation already owns the span; keep this helper in that context. */ @@ -953,7 +928,8 @@ const withToolAnalytics = }), ); }), - }), + }); + }), }); return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); }); 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.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index 1ac02357a878c..84676e7ccb1b4 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -19,6 +19,7 @@ export const layer = Layer.succeed( browserToolsAvailable: true, }, }), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. resolve: () => Effect.succeed(undefined), touch: () => Effect.void, revokeProviderSession: () => Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 04c33a1509e94..962beb1aed6c7 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 { @@ -109,6 +106,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([ @@ -215,12 +213,6 @@ export const layer = Layer.effect(McpSessionRegistry, make); export const touchActiveMcpThread = (threadId: ThreadId): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.touch(threadId) : Effect.void; -const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => - activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; - -const revokeAllActiveMcpCredentials = (): Effect.Effect => - activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; - export const __testing = { make: makeWithOptions, }; diff --git a/apps/server/src/mcp/McpToolAccess.test.ts b/apps/server/src/mcp/McpToolAccess.test.ts index 7bd0d66fa0406..d1a21fbb71957 100644 --- a/apps/server/src/mcp/McpToolAccess.test.ts +++ b/apps/server/src/mcp/McpToolAccess.test.ts @@ -165,7 +165,7 @@ export const refusedAtCompileTime = () => { // @ts-expect-error a declaration built outside McpToolAccess McpToolAccess.Declaration.make(unchecked); // @ts-expect-error a declaration constructed directly - new McpToolAccess.Declaration(unchecked); + new McpToolAccess.Declaration(unchecked); // oxlint-disable-line eslint/no-new -- This type-contract test checks direct construction. // @ts-expect-error a handlers layer built outside McpToolAccess McpToolAccess.HandlersLayer.make(Layer.empty); // @effect-diagnostics-next-line anyUnknownInErrorContext:off @@ -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..1eb4cf8c12f84 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) { @@ -109,7 +102,7 @@ export const readsAsCaller = (handle: (params: P) => Effect.Effect
(handle: (params: P) => Effect.Effect) => declare((params: P) => requireThreadCaller.pipe( - Effect.flatMap(() => writingCaller), + Effect.andThen(writingCaller), Effect.flatMap(() => handle(params)), ), ); @@ -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 9d2f10bc6c02b..d7f9746f76ea5 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -468,7 +468,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", @@ -512,7 +511,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.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6412fbaabbb49..54aa298b5433a 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, @@ -90,7 +91,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [ { id: childRunId, @@ -224,7 +225,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"), @@ -293,6 +294,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"); @@ -317,7 +426,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [], contextTransfers: [], messages: [], @@ -399,9 +508,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" ? [] @@ -504,7 +613,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -584,7 +693,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -670,7 +779,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -765,7 +874,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: [], @@ -993,7 +1102,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 7e58ac93640e8..27765c6be5501 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, @@ -67,10 +68,11 @@ import { } from "@t3tools/contracts"; import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -174,9 +176,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, @@ -214,13 +214,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, @@ -380,7 +374,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."); } @@ -399,11 +392,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, @@ -851,7 +840,6 @@ function threadSnooze( }; } -/** The same links `list_thread_pull_requests` reports, without the snapshot details. */ function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], ): OrchestratorMcpThreadPullRequest[] { @@ -872,6 +860,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, @@ -904,6 +893,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", @@ -1231,10 +1221,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, @@ -1310,11 +1297,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, @@ -1357,13 +1340,7 @@ const make = Effect.gen(function* () { readonly projectId: ProjectId; 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; @@ -1638,9 +1615,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, @@ -1673,6 +1651,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) && @@ -1715,8 +1697,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)); @@ -1844,11 +1825,9 @@ const make = Effect.gen(function* () { : "bindToCurrentThread binds to this thread, which belongs to a different project.", ); } - // A client caller inherits the project default, which is only - // required when there is no target to name a provider. Only an - // explicit target is checked against the providers: a task saved - // without one keeps working while its provider is briefly - // unavailable, as it always has. + // A client caller needs the project default only without a target. Only an explicit target + // is checked against the providers, so a task saved without one keeps working while its + // provider is briefly unavailable. const inherited = parent?.thread.modelSelection ?? project.defaultModelSelection ?? undefined; const modelSelection = input.target === undefined @@ -1937,9 +1916,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 @@ -2032,6 +2009,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( @@ -2110,6 +2088,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( @@ -2503,29 +2487,27 @@ 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)) return false; - yield* threadManagement - .dispatch({ - type: "subagent.stop", - commandId: stableCommandId({ - scope, - requestKey: key, - operation: "cancel-task-orphan", - }), - threadId: scope.thread.threadId, - runId: parentTask.runId, - subagentId: parentTask.id, - }) - .pipe(Effect.mapError(threadManagementFailure)); - const { subagents } = yield* threadManagement - .getThreadRecords(scope.thread.threadId, ["subagents"]) - .pipe(Effect.mapError(threadManagementFailure)); + yield* threadManagement.dispatch({ + type: "subagent.stop", + commandId: stableCommandId({ + scope, + requestKey: key, + operation: "cancel-task-orphan", + }), + threadId: scope.thread.threadId, + runId: parentTask.runId, + subagentId: parentTask.id, + }); + const { subagents } = yield* threadManagement.getThreadRecords(scope.thread.threadId, [ + "subagents", + ]); return subagents.some((task) => task.id === input.taskId && task.status === "cancelled"); - }); + }).pipe(Effect.mapError(threadManagementFailure)); if (isTerminalTaskStatus(current.status)) { // The child can still run work for another turn, so it is stopped either way. yield* stopWithinLimit; @@ -2722,6 +2704,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, @@ -2913,6 +2896,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 ? {} : { taskId: result.run.delegatedTaskId }), @@ -2921,7 +2905,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -2933,11 +2917,33 @@ const make = Effect.gen(function* () { ), }) .pipe(Effect.mapError(threadManagementFailure)); + 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, + ...(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 7ae8bfbefead5..f9d618f0520e3 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1027,7 +1027,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 c1ae21dfd3b4c..53622f3054775 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -93,11 +93,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"]; @@ -166,11 +162,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}`; @@ -205,6 +197,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 = ( @@ -253,11 +253,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({ @@ -357,8 +362,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 { @@ -515,9 +519,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) => @@ -550,8 +552,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 = @@ -612,8 +613,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { const offered = yield* SynchronizedRef.modifyEffect(state, (current) => { - // A route can outlive its generation while another request evicts it. - // Serialize the live-generation check and offer with queue closure. + // Serialize generation checks and offers with queue closure; routes can outlive eviction. if ( current.clients.get(connection.clientId)?.queue !== connection.queue || !current.pending.has(requestId) @@ -642,23 +642,19 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { } return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } - // The browser starts its operation timer after delivery. Allow its timeout - // response to arrive before treating the entire host as unresponsive. + // The browser timer starts on delivery; allow its timeout response before evicting the host. const responseTimeoutMs = input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS; const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs)); return yield* Option.match(result, { - onNone: () => - Effect.gen(function* () { - // Do not replay actions: the client may have applied them before - // becoming unreachable. 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. - if (input.updateCurrentTab !== false) { - yield* disconnect(connection.clientId, connection.queue, true); - } - return yield* new PreviewAutomationTimeoutError(requestContext); - }), + // The enclosing operation owns the span for this inline handler. + onNone: Effect.fnUntraced(function* () { + // Never replay possibly applied actions; slow metadata reads must not evict the host. + if (input.updateCurrentTab !== false) { + yield* disconnect(connection.clientId, connection.queue, true); + } + return yield* new PreviewAutomationTimeoutError(requestContext); + }), onSome: (value) => Effect.succeed(value as A), }); }); diff --git a/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts b/apps/server/src/mcp/TaskCancelNativeSubagent.integration.test.ts index 9c36a26045dc5..504ae749a78ac 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 3902068894d63..3be8705cc0bb0 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -348,13 +348,9 @@ 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; - }).pipe(Effect.provide(harness.layer)); + WorktreeMcpService.WorktreeMcpService.pipe(Effect.provide(harness.layer)); const runHandoff = ( harness: ReturnType, @@ -371,7 +367,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 461b7044ba87f..8f02ee1546424 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -63,9 +63,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) => @@ -225,14 +223,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 worktree = yield* restore( @@ -273,10 +264,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 - // attached 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 !== null) { return yield* alreadyInWorktree(recheck.thread.worktreePath); @@ -309,18 +297,13 @@ const make = Effect.gen(function* () { ), ); }).pipe( - // 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) => 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 @@ -401,11 +384,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/attachment/tools.ts b/apps/server/src/mcp/toolkits/attachment/tools.ts index 62c44c9363abe..b946233d22b1e 100644 --- a/apps/server/src/mcp/toolkits/attachment/tools.ts +++ b/apps/server/src/mcp/toolkits/attachment/tools.ts @@ -15,6 +15,7 @@ import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; import * as ServerConfig from "../../../config.ts"; +import * as DocumentText from "../../../provider/DocumentText.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -33,21 +34,21 @@ const shared = { const AttachmentUploadTool = Tool.make("t3_attachment_prepare_upload", { ...shared, description: - "Create the app's signed upload URL. POST the exact bytes to relativeUrl on this MCP server's HTTP origin, then pass the attachment metadata and returned ID to t3_thread_send_attachments. Upload is separate from sending; provider attachment support is decided by its adapter.", + "Get an upload URL. POST the file's exact bytes to `relativeUrl` on this MCP server's HTTP origin, then send via `t3_thread_send_attachments` with the returned `attachmentId`.", parameters: Schema.Struct({ upload: AttachmentCreateUploadUrlInput }), success: AttachmentCreateUploadUrlResult, }).annotate(Tool.Destructive, true); const AttachmentDiscardTool = Tool.make("t3_attachment_discard", { ...shared, - description: - "Discard a pending upload. Already-delivered thread attachments are never deleted by this operation.", + description: "Discard a pending upload (not a sent attachment).", parameters: AttachmentDeleteInput, success: Schema.Struct({}), }).annotate(Tool.Destructive, true); const AttachmentSendTool = Tool.make("t3_thread_send_attachments", { ...shared, + dependencies: [...shared.dependencies, DocumentText.DocumentText], description: - "Send uploaded attachments to this thread or any other thread in this environment. Each call is a new message, without a retry key. Acceptance does not mean the provider can consume the attachment or has finished the turn. The target cannot have broader permission modes than the caller; failures retain claimed files when dispatch outcome is uncertain.", + "Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))), diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 02216cec576de..503f745bb81e6 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,3 +1,4 @@ +import * as DocumentText from "../../provider/DocumentText.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; @@ -18,6 +19,7 @@ import { type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; @@ -48,6 +50,7 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as McpHttpServer from "../McpHttpServer.ts"; import * as McpInvocationContext from "../McpInvocationContext.ts"; import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; +import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; @@ -57,8 +60,6 @@ const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); -// 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; @@ -268,7 +269,7 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both Layer.provide(McpProviderSessions.layer), Layer.provide(PreviewBrowser.layer), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-html-render-" })), - Layer.provide(NodeServices.layer), + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), Layer.provide(FetchHttpClient.layer), Layer.provide( Layer.mock(ThreadManagement.ThreadManagementService)({ @@ -287,6 +288,61 @@ it.effect("returns an HTML render reference that Codex and Claude tool rows both ), ); +it.effect("records only failed calls of tools that hand off no work", () => { + // Recording runs detached from the tool response, so the first event is awaited. + const recorded = Deferred.makeUnsafe<{ event: string; properties: object }>(); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const organize = (capabilities: McpInvocationContext.McpInvocationScope["capabilities"]) => + server.callTool({ name: "t3_thread_organize", arguments: { action: "pin" } }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...scope, + capabilities, + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect((yield* organize(scope.capabilities)).isError).not.toBe(true); + expect(declaredFailure(yield* organize(new Set()))).toMatchObject({ + code: "capability_denied", + }); + // The successful call ran first, so it would be the first event if it were recorded. + expect(yield* Deferred.await(recorded)).toMatchObject({ + event: "mcp.tool.invoked", + properties: { tool: "t3_thread_organize", outcome: "error" }, + }); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: ({ threadId: id }) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), + ), + dispatch: () => Effect.succeed({ sequence: 1 } as never), + }), + ), + Layer.provide( + Layer.succeed( + AnalyticsService.AnalyticsService, + AnalyticsService.AnalyticsService.of({ + record: (event, properties) => + Deferred.succeed(recorded, { event, properties: properties ?? {} }).pipe( + Effect.asVoid, + ), + flush: Effect.void, + recordOwners: () => Effect.void, + }), + ), + ), + ), + ), + ); +}); + it.effect("keeps unexpected handler defects private through the production registration", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; @@ -827,7 +883,7 @@ it.effect("only the caller that prepared a pending upload can discard it", () => Layer.provide(McpToolAccessTestkit.liveThreadsLayer), Layer.provide(ServerSecretStore.layer), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-attachment-" })), - Layer.provide(NodeServices.layer), + Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), ), ), diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index 31d8c41dccf4d..f02f39b32462a 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -29,7 +29,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.", + "List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call 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. parameters: Schema.Struct({ @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", + "Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, @@ -70,7 +70,7 @@ const DeviceOpenTool = Tool.make("device_open", { export const DeviceScreenshotTool = Tool.make("device_screenshot", { description: - "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", + "Get a PNG of an open device's screen. For taps and text, use the `agent-device` CLI.", parameters: DeviceToolTargetInput, success: DeviceToolScreenshotResult, failure: DeviceToolFailure, @@ -84,11 +84,9 @@ export const DeviceScreenshotTool = Tool.make("device_screenshot", { const DeviceCloseTool = Tool.make("device_close", { description: - "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.", + "Remove a device from this thread's Device panel. `shutdown=true` also turns it off.", parameters: DeviceToolCloseInput, - success: Schema.Record(Schema.String, Schema.Never).annotate({ - description: "The device was closed.", - }), + success: Schema.Record(Schema.String, Schema.Never), failure: DeviceToolFailure, dependencies, }) diff --git a/apps/server/src/mcp/toolkits/environment/tools.ts b/apps/server/src/mcp/toolkits/environment/tools.ts index e441174900ce1..d684ff02e3402 100644 --- a/apps/server/src/mcp/toolkits/environment/tools.ts +++ b/apps/server/src/mcp/toolkits/environment/tools.ts @@ -40,7 +40,7 @@ const shared = { const EnvironmentReadTool = Tool.make("t3_environment_read", { ...shared, description: - "Read this server's identity and selected environment preferences. Provider/model availability is exposed by orchestrator_capabilities. Writing instructions are limited to 4,000 characters.", + "Read this server's identity and preferences. Providers and models: `orchestrator_capabilities`.", success: Schema.Struct({ environmentId: ExecutionEnvironmentDescriptor.fields.environmentId, label: Schema.String, @@ -54,7 +54,7 @@ const EnvironmentReadTool = Tool.make("t3_environment_read", { const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update", { ...shared, description: - "Update selected environment-wide preferences through normal settings persistence and notifications. Requires a live full-access/default calling thread. Omitted fields are preserved; empty customInstructions clears them.", + "Update environment-wide preferences. Live full-access or default caller thread only. Omitted fields are unchanged; `customInstructions: ''` clears it.", parameters: Schema.Struct({ defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode, newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin, diff --git a/apps/server/src/mcp/toolkits/html/handlers.ts b/apps/server/src/mcp/toolkits/html/handlers.ts index edd7146f81d00..1a402ca0bb4e1 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 24859c5762886..6f67d58a8a38c 100644 --- a/apps/server/src/mcp/toolkits/html/tools.ts +++ b/apps/server/src/mcp/toolkits/html/tools.ts @@ -21,20 +21,20 @@ const Html = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(512_0 }); const PAGE_RULES = - 'Write one self-contained document with inline +
+
+
`; + + const screenshotOf = async (html: string) => { + const page = await context.newPage(); + await page.setContent(html); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + // The server has no DOM types, so the decoder runs as page source. + const points = [ + [0.15, 0.75], + [0.85, 0.25], + [0.85, 0.75], + ]; + const pixels = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(screenshot.data)}), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return ${JSON.stringify(points)}.map(([x, y]) => + Array.from(context.getImageData(x * bitmap.width, y * bitmap.height, 1, 1).data), + ); + })()`)) as Array<[number, number, number, number]>; + return { screenshot, pixels }; + }; + const colorOf = ([red, green, blue]: readonly [number, number, number, number]) => + blue - red > 100 ? "blue" : red - blue > 100 ? "red" : `rgb(${red}, ${green}, ${blue})`; + + it("captures a blurred backdrop-filter page as rendered, not mirrored", async () => { + const { screenshot, pixels } = await screenshotOf(halves("backdrop-filter: blur(4px);")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("captures a plain page at the shrunk size with its content in place", async () => { + const { screenshot, pixels } = await screenshotOf(halves("")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { + const page = await context.newPage(); + await page.goto("data:text/xml,
"); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + + expect([screenshot.width, screenshot.height]).toEqual([1280, 800]); + }); +}); + describe("server browser drag", () => { it("fails the drag, not the server, when it times out while the cursor is still moving", async () => { const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded."); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 2355029258396..9525ac7c5b579 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -40,7 +40,10 @@ export class ServerBrowserOperationError extends Error { } } -export const toOperationError = (cause: unknown): ServerBrowserOperationError => { +export const toOperationError = ( + cause: unknown, + timeoutMs?: number, +): ServerBrowserOperationError => { if (cause instanceof BrowserControlInterrupted) return new ServerBrowserOperationError( "PreviewAutomationControlInterruptedError", @@ -51,7 +54,11 @@ export const toOperationError = (cause: unknown): ServerBrowserOperationError => const message = cause instanceof Error ? cause.message : String(cause); const firstLine = message.split("\n")[0] ?? message; if (cause instanceof Error && cause.name === "TimeoutError") { - return new ServerBrowserOperationError("PreviewAutomationTimeoutError", firstLine); + return new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + firstLine, + timeoutMs === undefined ? undefined : { timeoutMs }, + ); } if ( /while parsing selector|Unknown engine|Unexpected token|strict mode violation/i.test(message) @@ -98,7 +105,6 @@ const targetLocator = ( ): Locator | null => { const selector = input.locator ?? input.selector; if (selector === undefined) return null; - // Ignore quoted/escaped CSS values when looking for a selector-engine boundary. const engines = selector.replace( /\\.|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, " ", @@ -168,7 +174,6 @@ export const withoutDataPayload = (url: string) => { : ({ t: "\t", n: "\n", r: "\r", '"': '"', "\\": "\\" }[code] ?? "\u0001"), ) .replace(/[\t\n\r]/g, "") - // eslint-disable-next-line no-control-regex -- URL parsing ignores these controls. .replace(/^[\u0000-\u0020]+/, ""); const parsed = /^data:([^,;]*)/i.exec(value); if (!parsed) return url; @@ -200,11 +205,9 @@ const withoutTreeDataPayloads = (tree: string) => // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; -// How long a frame in another process gets to answer; one that crashed never does. const FRAME_ANSWER_MS = 1_000; -// Values the page draws as dots (password fields, and text fields whose CSS -// draws dots), normalized like accessible text. Open shadow roots included. +// Include password and CSS-masked text in open shadow roots, normalized like accessible text. const DOTTED_SCRIPT = `(() => { const values = []; const visit = (root) => { @@ -237,10 +240,10 @@ const tooBusy = () => const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; -/** A frame in another process keeps one CDP session, so none pile up across snapshots. */ +/** Reuse one CDP session per out-of-process frame across snapshots. */ const frameSessions = new WeakMap>(); -/** The value, or undefined when it fails or takes longer than a frame may. */ +/** Returns undefined on failure or frame timeout. */ const answered = (promise: Promise) => { let timer: ReturnType | undefined; const late = new Promise((resolve) => { @@ -249,14 +252,7 @@ const answered = (promise: Promise) => { return Promise.race([promise.catch(() => undefined), late]).finally(() => clearTimeout(timer)); }; -/** - * Reads dotted values in isolated worlds, where page script can't change what `value` - * returns, through the CDP session of the process each frame renders in. A frame in - * another process that can't be attached, as in desktop tabs where only the page can, - * is read in the page's own world, where its script can change `value`. `read` gives - * each reader's values, or undefined for one that didn't answer, and fails when the - * page's own frames don't or frames came or went. - */ +/** Isolated worlds prevent spoofing; desktop out-of-process frames use page worlds. Missing own-frame reads or changed frames fail; other missing reads return undefined. */ const openReaders = async (page: Page, cdp: CDPSession) => { const frames = page.frames(); const owners = new Map( @@ -269,8 +265,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { return [frame, await session] as const; } catch (cause) { frameSessions.delete(frame); - // CdpRelay refuses all but the page. Any other refusal reads the frame through its - // parent's session, which fails the read if the frame isn't there. + // CdpRelay supports only the page; other refusals fall back to the parent session, failing if the frame is absent. const desktop = String(cause).includes("Only this preview tab's page can be attached"); return [frame, desktop ? null : undefined] as const; } @@ -342,10 +337,8 @@ const openReaders = async (page: Page, cdp: CDPSession) => { ]; return { readers, - /** Readers that didn't answer `before` aren't asked again. */ read: async (before?: ReadonlyArray) => { const now = page.frames(); - // A frame added since the readers opened has none. if (now.length !== frames.length || now.some((frame, i) => frame !== frames[i])) { throw pageChanged(); } @@ -359,12 +352,7 @@ const openReaders = async (page: Page, cdp: CDPSession) => { }; }; -/** - * Matches each value as the snapshot prints it: raw, as a JSON name, as a - * quoted value, and with '' for each quote inside a quoted key. A value under - * four characters only matches between non-word characters; inside other words - * it would mask unrelated text. - */ +/** Short values need word boundaries to avoid masking unrelated text. */ const dottedPattern = (values: ReadonlyArray) => { const sources = values .flatMap((value) => { @@ -387,8 +375,7 @@ const dottedPattern = (values: ReadonlyArray) => { if (sources.length === 0) return undefined; const pattern = new RegExp(sources.join("|"), "gu"); try { - // Compiles it now, for one-byte and two-byte text: one too large fails only when it - // first runs on each, naming every value. + // Force regex compilation for one-byte and two-byte strings now; size errors name every value. pattern.test(""); pattern.test("\u2022"); } catch { @@ -397,16 +384,11 @@ const dottedPattern = (values: ReadonlyArray) => { return pattern; }; -// `- role "name" [attribute]...: value`, the key single-quoted when it holds ": ". A name -// that starts and ends with "/" prints unquoted. Only Playwright's own attributes count, so -// text around a value in a name can't pass for one. +// Playwright single-quotes keys containing ': ' and leaves /.../ names unquoted. Recognize only its attributes so name text cannot spoof them. const TREE_LINE = /^(\s*- '?[^\s"':]+)( "(?:[^"\\]|\\.)*"| \/(?:.*?\/)?)?((?: \[(?:active|checked(?:=mixed)?|disabled|expanded|level=\d+|pressed(?:=mixed)?|selected|ref=(?:f\d+)?e\d+|cursor=pointer|box=-?\d+,-?\d+,\d+,\d+)\])*'?)(:.*)?$/; -/** - * Masks each value on a line, except one that only matches inside the role or attributes, - * so refs keep working. A "/.../" name has no closing mark, so a value can seem to cross it. - */ +/** Preserve roles and attributes so refs work; unquoted /.../ names can make matches cross boundaries. */ const maskTree = (tree: string, dotted: RegExp) => tree .split("\n") @@ -428,8 +410,6 @@ const pngSize = (base64: string) => { return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; }; -type Viewport = { readonly width: number; readonly height: number }; - /** * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. @@ -456,13 +436,7 @@ const readPageMetrics = async (cdp: CDPSession) => { readonly width: number; readonly height: number; }; - return { - ratio, - width, - height, - zoom, - offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }, - }; + return { ratio, width, height, zoom }; } } throw new Error( @@ -470,41 +444,56 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter` +// in any capture it shrinks. export const captureViewport = async ( - page: Page, cdp: CDPSession, - options: { - readonly format: "png" | "jpeg"; - readonly quality?: number; - readonly scale: number; - readonly viewport?: Viewport; - readonly offset?: { readonly x: number; readonly y: number } | undefined; - }, + options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - let clip; - if (options.scale < 1) { - const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; - let offset = options.offset; - if (!offset) { - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - const zoom = cssVisualViewport.zoom ?? 1; - offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }; - } - clip = { - ...offset, - ...viewport, - scale: options.scale, - }; - } const { data } = await cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip ? { clip } : {}), }); return data; }; +/** Runs in the page's own isolated world, as the server has no image library. */ +const SHRINK_PNG = `async function (png, width, height) { + const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + // Namespaced, as an XML document's createElement makes a plain element with no getContext. + const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.imageSmoothingQuality = "high"; + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return canvas.toDataURL("image/png").slice("data:image/png;base64,".length); +}`; + +const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => { + const size = pngSize(png); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-screenshot", + }); + const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", { + functionDeclaration: SHRINK_PNG, + executionContextId, + arguments: [ + { value: png }, + { value: Math.round(size.width * scale) }, + { value: Math.round(size.height * scale) }, + ], + awaitPromise: true, + returnByValue: true, + }); + if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot."); + return result.value as string; +}; + type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; @@ -515,17 +504,16 @@ type SnapshotInput = { readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }; -/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */ +/** The scale a page renders at and its viewport in device-independent pixels. */ const renderingOf = async (input: SnapshotInput) => { if (input.renderScale !== "display") { const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; - return { renderScale: input.renderScale, viewport, offset: undefined }; + return { renderScale: input.renderScale, viewport }; } // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels. const page = await readPageMetrics(input.cdp); return { renderScale: page.ratio / page.zoom, - offset: page.offset, viewport: { width: Math.round(page.width * page.zoom), height: Math.round(page.height * page.zoom), @@ -537,7 +525,7 @@ const capture = async ( input: SnapshotInput, deadline: number, ): Promise => { - const { renderScale, viewport, offset } = await renderingOf(input); + const { renderScale, viewport } = await renderingOf(input); // A late metrics reply must not begin a capture after viewers have resumed. if (Date.now() >= deadline) throw tooBusy(); const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); @@ -546,8 +534,7 @@ const capture = async ( const generation = state.generation; const { readers, read } = await openReaders(input.page, input.cdp); const before = await read(); - // Taken between the reads, so a value that changes during either fails the comparison. - // Awaited after the tree: a capture that fails has finished its tree before a retry starts. + // Bracket the tree with value reads to detect changes; await tree completion before retrying failed captures. const pageAndScreenshot = Promise.all([ input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< Pick< @@ -555,7 +542,9 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + captureViewport(input.cdp, { format: "png" }).then((png) => + scale < 1 ? shrinkPng(input.cdp, png, scale) : png, + ), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. @@ -569,8 +558,7 @@ const capture = async ( if (after.some((values, i) => values && JSON.stringify(values) !== JSON.stringify(before[i]))) throw pageChanged(); const dotted = dottedPattern(after.flatMap((values) => values ?? [])); - // A frame that didn't answer both times, such as one that crashed, can only hold values the - // tree shows if the tree shows its content. + // Unread frames can expose values only if their content appears in the tree. const unread = readers.flatMap((reader, i) => (after[i] ? [] : reader.frames)); const filled = /^\s*- iframe (?:\[active\] )?\[ref=(\S+?)\].*:$/gm; for (const [, ref] of unread.length ? tree.matchAll(filled) : []) { @@ -595,8 +583,7 @@ const capture = async ( }); return { ...page, - // Text that holds a value would otherwise show where the tree masked it. Masked before - // the cut, so a value crossing it can't leave its start showing. + // Mask text before truncating so a value crossing the cut cannot leak its prefix. visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH), accessibilityTree, consoleEntries: [...input.consoleEntries], @@ -621,9 +608,7 @@ export const snapshot = async (input: SnapshotInput): Promise Date.now() < deadline && snapshotTurns.get(input.page) === turn && @@ -640,10 +625,7 @@ export const snapshot = async (input: SnapshotInput): Promise { (locator !== null || (await target.count()) > 0) && (await target.evaluate( (element) => { - // Like the desktop host: an enabled text control or contenteditable - // that actually takes focus. Anything else would swallow the text or - // send it to whichever field had focus before. + // Require an enabled, focused text target to avoid losing input or sending it to the previous field. const control = element as unknown as { readonly type?: string; readonly disabled?: boolean; @@ -792,8 +772,7 @@ export const drag = async ( const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); - // Observe the drag's outcome as it starts: a rejection nobody handles while the cursor - // moves exits the server. + // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. const dragFailure = source.dragTo(target, { timeout }).then( () => null, (error: unknown) => ({ error }), @@ -826,11 +805,7 @@ export const scroll = async (page: Page, input: PreviewAutomationScrollInput) => await locator.evaluate((element, [x, y]) => element.scrollBy(x, y), delta); }; -/** - * Runs an agent's expression, bounded by its request's deadline. Past it, the - * page's script is terminated and the call fails, so the tab's control queue - * frees for the next request instead of waiting on the script. - */ +/** Terminate expired page scripts so a hung expression cannot block the tab's control queue. */ export const evaluate = async ( cdp: CDPSession, input: PreviewAutomationEvaluateInput, diff --git a/apps/server/src/preview/ServerBrowserStream.test.ts b/apps/server/src/preview/ServerBrowserStream.test.ts index 6b2d24802d247..d3f4781829a85 100644 --- a/apps/server/src/preview/ServerBrowserStream.test.ts +++ b/apps/server/src/preview/ServerBrowserStream.test.ts @@ -133,8 +133,7 @@ it.effect.each([ received.resolve(); }); yield* Effect.promise(() => received.promise); - // The ack follows every mutation on the socket, so this is also a barrier - // proving all preceding inputs were processed, without a timing sleep. + // The socket ack follows every mutation, providing a barrier without sleeps. yield* Effect.promise(() => acked.promise); expect(frames).toEqual([frame]); expect(viewports).toEqual([{ type: "viewport", width: 1280, height: 800 }]); @@ -215,7 +214,6 @@ it.effect("sends replies ahead of stale frames and only the newest frame after a ]); yield* Effect.promise(() => next.promise); expect(received).toEqual([1, "probe"]); - // Frames 2 and 3 were superseded before they could be sent. expect(released).toEqual([2, 3]); next = Promise.withResolvers(); diff --git a/apps/server/src/preview/ServerBrowserStream.ts b/apps/server/src/preview/ServerBrowserStream.ts index ad42eaf5d729d..83d4adde7e2a8 100644 --- a/apps/server/src/preview/ServerBrowserStream.ts +++ b/apps/server/src/preview/ServerBrowserStream.ts @@ -176,12 +176,13 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr } }), ); - const receive = (chunk: Uint8Array | string) => { + // Incoming messages share the session span; frame acknowledgements are frequent. + const receive = Effect.fnUntraced(function* (chunk: Uint8Array | string) { const message = parseMessage(chunk); // Ownership serializes actions in the service. Keep reading so dialog // replies and takeover can unblock an action already waiting on the page. if (!isAck(message)) - return canOperate + return yield* canOperate ? viewer.input(message).pipe(Effect.forkScoped, Effect.asVoid) : Effect.void; const ack = inFlight; @@ -189,11 +190,11 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr inFlight = null; pending = null; // Forked: Chromium acks are paced and must not hold up input. - return Effect.andThen( + return yield* Effect.andThen( ack ? Effect.forkScoped(ack) : Effect.void, next ? sendFrame(next) : Effect.void, ); - }; + }); const receiveInput = reader.pull.pipe( Effect.flatMap((chunks) => Effect.forEach(chunks, receive, { discard: true })), ); @@ -206,7 +207,7 @@ const makeHandler = Effect.fnUntraced(function* (browser: ServerBrowser.ServerBr Effect.as(HttpServerResponse.text("Service Unavailable", { status: 503 })), ), }), - Effect.catch(() => Effect.succeed(HttpServerResponse.empty())), + Effect.orElseSucceed(() => HttpServerResponse.empty()), ); }); diff --git a/apps/server/src/preview/SessionControl.ts b/apps/server/src/preview/SessionControl.ts index cee60e2f95321..de02f32a1f774 100644 --- a/apps/server/src/preview/SessionControl.ts +++ b/apps/server/src/preview/SessionControl.ts @@ -110,10 +110,7 @@ export class SessionControl { return this.agentMayAct(agentId) && this.owner === null; } - /** - * A read that needs no control and does not queue, so an agent can look at - * a page while a human drives it without holding up the human's input. - */ + /** Read without control or queueing so agents can observe without delaying human input. */ observe(run: () => Promise) { this.assertOpen(); return run(); diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index d923a1bc7e9ed..1e54691161ae4 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -48,11 +48,7 @@ export interface ScopeState { readonly populated: boolean | undefined; } -/** - * Our pipe can close before systemd handles the kill, so a scope that is - * still stopping, is empty, or already counted an OOM kill is "stopping": - * systemd records `Result=oom-kill` for it in a moment. - */ +/** Pipes can close before systemd records oom-kill; stopping, empty or OOM-counted scopes remain pending. */ export function classifyScope(state: ScopeState): "oom-killed" | "gone" | "running" | "stopping" { if (state.result === "oom-kill") return "oom-killed"; if ( @@ -86,8 +82,7 @@ function parseCgroupCounter(text: string, key: string): number | undefined { return match === null ? undefined : Number(match[1]); } -// A stop sends SIGTERM, then SIGKILL after this long. systemd's 90 s default -// would outlast the wait for a previous server's agents below. +// systemd's default 90 s stop timeout would outlast our leftover-agent wait. const STOP_TIMEOUT_OPTION = "--property=TimeoutStopSec=10s"; const STOPPING_POLL_INTERVAL = "100 millis"; const STOPPING_POLL_ATTEMPTS = 20; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 73ff1b067e875..1e816f31b77d5 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -19,10 +19,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as ExternalLauncher from "./externalLauncher.ts"; -// Tests below write `#!/bin/sh` stubs into a real temp dir and hand that -// directory to a posix-mocked resolver as PATH. On a Windows host the temp -// path carries a drive letter, so the posix `:` split shatters it; there is -// no posix executable to find there anyway. +// Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; interface MockSpawnResult { @@ -183,17 +180,6 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -for (const {} of ["darwin", "linux"] as const) { -} - -for (const {} of [ - { kind: "browser", folderVerb: "none" }, - { kind: "reveal", folderVerb: "none" }, - { kind: "reveal", folderVerb: "" }, - { kind: "reveal", folderVerb: "open" }, -] as const) { -} - it.effect.skipIf(windowsHost)( "reveals a WSL file in Windows File Explorer through its UNC path", () => @@ -308,91 +294,6 @@ for (const wsl of [false, true]) { ); } -for (const {} of ["rejected", "spawn-error", "stalled"] as const) { -} - -for (const {} of [ - { - platform: "darwin", - installPath: "Applications/Antigravity IDE.app/Contents/Resources/app/bin/antigravity-ide", - editor: "antigravity", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "linux", - installPath: ".local/bin/antigravity-ide", - editor: "antigravity", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/Cursor.app/Contents/Resources/app/bin/code", - editor: "cursor", - args: ["--classic", "--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/Kiro.app/Contents/Resources/app/bin/code", - editor: "kiro", - args: ["--goto", "/workspace with spaces/file.ts:12:4"], - }, - { - platform: "darwin", - installPath: "Applications/WebStorm.app/Contents/MacOS/webstorm", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "darwin", - installPath: "Applications/Zed.app/Contents/MacOS/cli", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, - { - platform: "win32", - installPath: "Programs/Cursor/resources/app/bin/cursor.cmd", - editor: "cursor", - args: ['^"--classic^"', '^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'], - }, - { - platform: "win32", - installPath: "Programs/Microsoft VS Code/bin/code.cmd", - editor: "vscode", - args: ['^"--goto^"', '^"/workspace^ with^ spaces/file.ts:12:4^"'], - }, - { - platform: "win32", - installPath: "Programs/JetBrains/WebStorm 2026.2/bin/webstorm64.exe", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "win32", - installPath: "Programs/WebStorm/bin/webstorm64.exe", - editor: "webstorm", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "win32", - installPath: "Programs/Zed/bin/zed.exe", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, - { - platform: "linux", - installPath: ".local/share/JetBrains/Toolbox/scripts/idea", - editor: "idea", - args: ["--line", "12", "--column", "3", "/workspace with spaces/file.ts"], - }, - { - platform: "linux", - installPath: ".local/bin/zed", - editor: "zed", - args: ["/workspace with spaces/file.ts:12:4"], - }, -] as const) { -} - for (const cwd of ["/workspace with spaces/", "/workspace with spaces/file.ts:12:4"]) { it.effect.skipIf(windowsHost)( `opens ${cwd} with the macOS Zed bundle when another zed is on PATH`, diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 1260f2e3fda80..b8c0911a520d2 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -66,13 +66,7 @@ interface TargetPathAndPosition { readonly column: Option.Option; } -/** - * Windows command shims (`code.cmd`) forward arguments through `%*`, which - * cmd.exe parses a second time after the first escaping layer is gone: a - * line break ends the command there, and a double quote closes the quoting - * that keeps `&` or `|` literal. Neither can occur in a Windows path. - */ -// oxlint-disable-next-line no-control-regex +// cmd shims reparse %* after escaping is gone; newlines and quotes can expose shell operators. const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/; const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ @@ -83,9 +77,7 @@ const POWERSHELL_ARGUMENTS_PREFIX = [ "-EncodedCommand", ] as const; -// Windows PowerShell started with DETACHED_PROCESS exits 0 without running its -// command, so native Windows launches through it stay attached. WSL interop -// PowerShell starts from a Linux process and keeps the detached launch. +// Detached Windows PowerShell exits 0 without executing; only WSL interop keeps detached launches. const WINDOWS_POWERSHELL_DETACHED = false; const DETACHED_IGNORE_STDIO_OPTIONS = { @@ -196,10 +188,7 @@ function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } -// File reveals from WSL resolve PowerShell through the interop PATH rather -// than the fixed /mnt/c mount: the automount root is configurable, and a -// PATH-resolved command keeps the advertised capability aligned with the -// availability check `launchEditor` performs before spawning. +// WSL automount roots vary; resolve PowerShell via PATH to match launchEditor's availability check. const WSL_POWERSHELL_COMMAND = "powershell.exe"; function shouldUseWindowsHostFromWsl( @@ -259,14 +248,8 @@ function fileManagerCommandForPlatform( } } -// A graphical session variable plus an executable `xdg-open` does not prove -// that opening a directory does anything: without an `inode/directory` MIME -// handler, `xdg-open` exits nonzero after the launcher has already detached, so -// the client would see a silent no-op. The probe carries its own timeout well -// inside the scan timeout `server.getConfig` applies to editor discovery: that -// outer timeout degrades to an empty editor list, so a hung `xdg-mime` (broken -// D-Bus or desktop session) must cost only the file manager, not every -// discovered editor. +// xdg-open needs an inode/directory handler or detached launches silently fail. +// Bound xdg-mime below discovery's timeout so a hung probe drops only the file manager. const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( @@ -319,10 +302,7 @@ const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManag }, ); -// WSL hosts prefer the Windows Explorer bridge, but interop can exist without -// `explorer.exe` on PATH (appendWindowsPath=false) or without a distro name -// while WSLg still provides a working Linux file manager, so they keep the -// `xdg-open` fallback instead of losing the editor entirely. +// WSL may lack Explorer on PATH or a distro name while WSLg still offers a Linux file manager. const resolveUsableFileManagerCommand = Effect.fn( "externalLauncher.resolveUsableFileManagerCommand", )(function* ( @@ -347,13 +327,8 @@ const resolveUsableFileManagerCommand = Effect.fn( return undefined; }); -// Reveal on Windows and WSL runs through PowerShell (see -// resolveFileManagerRevealLaunch), not the `explorer` command that gates the -// file-manager editor itself, so the capability must probe the executables the -// reveal actually spawns. Callers gate on file-manager availability first; -// the Linux "files" kind relies on that gate for the directory-handler probe, -// while the WSL fallback re-probes because its availability may have come -// from the Explorer bridge instead. +// Reveal uses PowerShell on Windows/WSL, so probe it separately from Explorer availability. +// Linux relies on the file-manager gate's handler probe; WSL re-probes because the gate may use Explorer. const fileManagerRevealKindForPlatform = Effect.fn( "externalLauncher.fileManagerRevealKindForPlatform", )(function* ( @@ -471,13 +446,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Callers apply a timeout (`resolveAvailableEditorsForConfig`) and disconnect -// mid-connect; neither may cancel a scan other connects are waiting on, or -// throw away work a slow host (a busy server at startup, a long PATH) needs -// more than one connect to finish. Expiry uses the monotonic clock -// (Clock.monotonicTimeNanos), matching the command-resolution cache in -// @t3tools/shared/shell, so a backward wall-clock adjustment cannot keep an -// expired entry alive. +// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { @@ -489,11 +458,7 @@ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect>; - /** - * Only meaningful when `resolveAvailableEditors` includes "file-manager": - * on Linux that availability check also carries the directory-handler probe - * this capability relies on. - */ + /** Requires file-manager in resolveAvailableEditors; Linux relies on its directory-handler probe. */ readonly resolveFileManagerRevealKind: () => Effect.Effect; readonly launchBrowser: (target: string) => Effect.Effect; readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; @@ -556,8 +521,7 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)] : platform === "win32" - ? // Explorer reads `/` as a switch, so `C:/repo` silently opens - // a default folder instead of the target. + ? // Explorer interprets / as a switch and silently opens a fallback folder. [normalizeWindowsFileManagerPath(input.cwd)] : [input.cwd], }; @@ -713,9 +677,7 @@ const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* ( onError: (cause: unknown) => ExternalLauncherError, ): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // The desktop app runs this server with ELECTRON_RUN_AS_NODE. A launched file - // manager or browser passes its environment on to everything started from it, - // so any Electron app opened from there would start as plain Node and exit. + // Inherited ELECTRON_RUN_AS_NODE would make Electron apps opened by this launcher run as Node and exit. const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment; const command = ChildProcess.make(launch.command, launch.args, { ...launch.options, @@ -789,7 +751,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -827,8 +789,7 @@ export const make = Effect.gen(function* () { Effect.interruptible, Effect.forkIn(scope), ); - // Claiming the cache entry and starting its scan must not be split by an - // interrupt, or the entry would wait on a scan that never runs. + // Claim and start atomically; interruption between them would leave an entry waiting on no scan. const acquireEditorDiscovery = Effect.gen(function* () { const nowNanos = yield* Clock.monotonicTimeNanos; const [scan, isNewScan] = yield* Ref.modify( @@ -856,20 +817,29 @@ export const make = Effect.gen(function* () { }).pipe(Effect.uninterruptible); const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ - resolveAvailableEditors: () => cachedAvailableEditors, - resolveFileManagerRevealKind: () => - provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchBrowser: (target) => - launchBrowser(target).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - launchEditor: (input) => - provideCommandResolutionServices(launchEditor(input)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveAvailableEditors: Effect.fnUntraced(function* () { + return yield* cachedAvailableEditors; + }), + resolveFileManagerRevealKind: Effect.fnUntraced( + function* () { + return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchBrowser: Effect.fnUntraced( + function* (target) { + return yield* launchBrowser(target); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + launchEditor: Effect.fnUntraced( + function* (input) { + return yield* provideCommandResolutionServices(launchEditor(input)); + }, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), }); }); diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 29d6dbd836597..46937833b7dd8 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -119,9 +119,7 @@ describe("runProcess", () => { it.effect("types spawn failures that the platform spawner throws synchronously", () => Effect.gen(function* () { - // A non-directory entry on PATH makes node's spawn throw ENOTDIR - // synchronously. The platform spawner calls it inside `Effect.callback`, - // so the throw arrives as a defect rather than a typed error. + // File-valued PATH entries make spawn throw ENOTDIR synchronously, arriving through Effect.callback as a defect. const defect = Object.assign(new Error("spawn jj ENOTDIR"), { code: "ENOTDIR" }); const spawner = makeSpawner(() => Effect.callback(() => { @@ -262,9 +260,6 @@ describe("runProcess", () => { expect(result.code).toBe(0); }), ); - - for (const {} of ["timeout", "success"] as const) { - } }); describe("isWindowsCommandNotFound", () => { diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 05640ec968d34..c10c5cce032e1 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -29,10 +29,7 @@ export interface ProcessRunInput { readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; - /** - * On timeout, return a synthetic timedOut result. - * Partial stdout/stderr are not preserved. - */ + /** Timed-out results discard partial stdout/stderr. */ readonly timeoutBehavior?: "error" | "timedOutResult" | undefined; } @@ -311,9 +308,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( ) .pipe( Effect.mapError(toSpawnError), - // Node's spawn can also throw synchronously, e.g. ENOTDIR when a PATH - // entry is a file. That arrives as a defect, which `mapError` never sees, - // so callers that recover from spawn failures would be bypassed. + // Synchronous spawn throws arrive as defects; recover them too so callers see typed spawn failures. Effect.catchDefect((cause) => Effect.fail(toSpawnError(cause))), ); @@ -393,7 +388,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index be7457da4335d..69026f91202af 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -209,18 +209,16 @@ const makeImporter = Effect.fn(function* ( }), Layer.mock(ProjectService.ProjectService)({ getById: () => - Effect.succeed( - Option.some({ - id: projectId, - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - createdAt: timestamp, - updatedAt: timestamp, - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }), }), Layer.mock(Orchestrator.OrchestratorV2)({ getThreadRecords: (threadId, fields) => diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts index f9fe7e9754197..84b318f0642cc 100644 --- a/apps/server/src/project/AgentSessionJson.ts +++ b/apps/server/src/project/AgentSessionJson.ts @@ -47,9 +47,7 @@ export function createTranscriptJsonReader( selectPath: (path: JsonPath) => boolean, options?: { readonly maxDepth?: number }, ) { - // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted - // from its bundled types. Unlike parser(), it does not wrap tokens in an - // async generator; the file reader already supplies backpressure and UTF-8. + // 3.6.0 omits the synchronous tokenizer from types; parser() adds an unnecessary async generator. const { jsonParser } = StreamJson as typeof StreamJson & { jsonParser: ( options: ParserOptions, @@ -88,9 +86,7 @@ export function createTranscriptJsonReader( assembler.consume(token); } }; - // Forward actual selected keys instead of reconstructing them from path - // changes: adjacent duplicate keys have the same path but JSON.parse keeps - // the last value. Reconstructing paths can silently retain the first value. + // Preserve actual keys; adjacent duplicates share a path, but JSON.parse keeps the last value. const stack: Array<{ path: JsonPath; key: string | number | null; selected: boolean }> = []; let selectedValue = false; const startValue = () => { diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 29a55ec22ba1d..baf0f27f9edb3 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -745,7 +745,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(submoduleGitDir, "config"), - '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + '[remote "origin"]\n\turl = ssh://gitlab.com/pingdotgg/libs/vendor.git\n', ); yield* fileSystem.writeFileString( path.join(submodule, ".git"), @@ -767,7 +767,10 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ).toEqual([ { path: submodule, - git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + git: { + remoteKey: "gitlab.com/pingdotgg/libs/vendor", + repository: "pingdotgg/libs/vendor", + }, }, { path: noRemote, git: { remoteKey: null, repository: null } }, { path: plain, git: null }, diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index e516ce0e53b99..9c26b187a55bf 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -24,20 +24,17 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import { - normalizeGitRemoteUrl, - isBareGitConfig, - parseGitHubRepositoryNameWithOwnerFromRemoteUrl, - parseOriginUrlFromGitConfig, -} from "@t3tools/shared/git"; +import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -56,11 +53,7 @@ const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; -/** - * Large tool results (especially screenshots) can make an otherwise ordinary - * Codex transcript several GiB. Streaming field selection avoids allocating - * those payloads. - */ +// Screenshot-heavy transcripts can reach GiB; select fields while streaming to avoid allocating payloads. const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORTED_MESSAGES = 200; const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; @@ -441,9 +434,7 @@ function parseAgentSessionRecords( if (record.type === "event_msg" && record.payload?.type === "user_message") { const text = record.payload.message ?? ""; if (text.trim().length === 0) continue; - // Codex can write the same prompt as both a response item and an event. - // Remove only the matching response copy so mixed-format logs keep every - // distinct user message. + // Codex can duplicate prompts across formats; remove only matching response copies. for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]; if (message?.role === "assistant") break; @@ -629,11 +620,10 @@ function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdent ); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - // Different project imports can arrive concurrently from multiple clients. - // Only one transcript may hold its selected-history budget at a time. + // Concurrent client imports must not hold multiple selected-history budgets. const importReadLock = yield* Semaphore.make(1); const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; @@ -725,8 +715,9 @@ export const make = Effect.gen(function* () { return { _tag: "Repository", git: { - remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), - repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + remoteKey: + originUrl === null ? null : sourceControlDefinitions.repositoryKeyFromRemote(originUrl), + repository: repositoryPathFromRemoteUrl(originUrl), }, } as const; }); @@ -1224,8 +1215,7 @@ export const make = Effect.gen(function* () { const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); - // A symlink can point into the worktrees directory even when its own - // spelling doesn't; check again with links resolved. + // Symlinks can disguise excluded worktree paths; re-check their targets. if (isExcludedProjectPath(realPath)) { key = ""; } else { @@ -1262,8 +1252,7 @@ export const make = Effect.gen(function* () { : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); } - // Resolve persisted roots too. A project and a transcript can name - // different symlinks to the same directory. + // Resolve persisted roots too; transcripts may use different symlinks to the same directory. const importedProjects = yield* projectStore .listShells() .pipe( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 449d7b8e00fc8..df6cb6f6b507e 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -98,10 +98,7 @@ export class ManagedProjectFolders extends Context.Service< readonly text: string; }) => Effect.Effect, ScratchFolderError>; readonly namedProjectsRoot: string; - /** - * The folder is removed when the create fails or is cancelled before the - * project exists, never once another project owns it. - */ + /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< { readonly projectId: ProjectId; @@ -151,10 +148,7 @@ function escapeXml(value: string): string { .replaceAll('"', """); } -/** - * It lives at `assets/icon.svg`, a path ProjectFaviconResolver already checks, - * so every machine that clones the project shows the same icon. - */ +// ProjectFaviconResolver finds assets/icon.svg on every clone. function namedProjectIconSvg(name: string): string { const words = name.split(/[^\p{L}\p{N}]+/u).filter(Boolean); const initials = @@ -201,7 +195,6 @@ function describeCommitFailure(stderr: string): string { const isVcsProcessSpawnError = Schema.is(VcsProcessSpawnError); -// Spawning a `git` that is not on PATH fails with NotFound, on Windows too. const isGitMissing = (error: GitManagerServiceError) => error._tag === "GitManagerError" && isVcsProcessSpawnError(error.cause) && @@ -217,10 +210,7 @@ const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const crypto = yield* Crypto.Crypto; - /** - * Each folder is created without `recursive`, so creating it is the claim: of - * two racers for one name, one gets AlreadyExists and moves on to the next. - */ + // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( folderFor: (attempt: number) => Effect.Effect, E>, onError: (folder: string, cause: PlatformError.PlatformError) => E, @@ -240,9 +230,7 @@ const make = Effect.gen(function* () { } }); - // Inside a checkout (a dev worktree's .t3, a dotfiles home) the folder would - // inherit the repo's git status and checkpoints, so Scratch is offered only - // when the data dir is outside any work tree. + // Scratch inside a checkout would inherit its git status and checkpoints; require a data dir outside work trees. const [probe, invalidate] = yield* Effect.cachedInvalidateWithTTL( gitWorkflow.isRepository(config.baseDir).pipe( Effect.catchIf(isGitMissing, () => Effect.succeed(false)), @@ -284,8 +272,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // bootstrap looks the root up before taking the workspace lock, so a - // racing create loses with a conflict that names the winner. + // bootstrap reads before locking; racing creates return the winner in a conflict. Effect.catchTags({ ProjectConflictError: (conflict) => Effect.succeed({ @@ -343,9 +330,9 @@ const make = Effect.gen(function* () { return yield* claimFreeFolder( (attempt) => attempt === 1 - ? Effect.succeed(Option.some(folderFor(id.slice(-8)))) + ? Effect.succeedSome(folderFor(id.slice(-8))) : attempt === 2 - ? Effect.succeed(Option.some(fullFolder)) + ? Effect.succeedSome(fullFolder) : crypto.randomUUIDv4().pipe( Effect.map((uuid) => Option.some(`${fullFolder}-${uuid.slice(0, 8)}`)), Effect.mapError((cause) => new ScratchFolderError({ folder: fullFolder, cause })), @@ -408,7 +395,7 @@ const make = Effect.gen(function* () { path.join(cwd, "assets", "icon.svg"), namedProjectIconSvg(name), ); - // Named and forced so a global ignore rule (say `*.svg`) cannot drop one. + // Force explicit filenames so global ignore rules cannot drop them. yield* git.execute({ operation: "ManagedProjectFolders.add", cwd, @@ -453,11 +440,7 @@ const make = Effect.gen(function* () { workspaceRoot, }) .pipe( - // A rejected create leaves the folder unused, so remove it. An - // interrupt can land after the create is committed, and a conflict - // means another project owns the folder, so it stays then; the owner - // is checked again first, since deleting another project's files is - // never acceptable. + // Re-check ownership before deleting rejected creations; interruption after commit or conflicts can leave an owner. Effect.tapError((error) => error._tag === "ProjectConflictError" ? Effect.void diff --git a/apps/server/src/project/ProjectCloneTracker.ts b/apps/server/src/project/ProjectCloneTracker.ts index 411d84fb38592..4a0c99795155c 100644 --- a/apps/server/src/project/ProjectCloneTracker.ts +++ b/apps/server/src/project/ProjectCloneTracker.ts @@ -44,10 +44,7 @@ export class ProjectCloneTracker extends Context.Service< /** Interrupts a running clone and deletes the partial checkout. */ readonly cancel: (projectId: ProjectId) => Effect.Effect; readonly retry: (projectId: ProjectId) => Effect.Effect; - /** - * Stops tracking a project's clone, interrupting it if it still runs and - * removing an unfinished checkout. - */ + /** Stops tracking, interrupts any running clone and removes unfinished checkouts. */ readonly discard: (projectId: ProjectId) => Effect.Effect; readonly get: (projectId: ProjectId) => Effect.Effect; readonly stream: Stream.Stream>; @@ -91,7 +88,7 @@ interface TrackedClone { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const repositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const clones = yield* Ref.make(new Map()); @@ -100,8 +97,7 @@ export const make = Effect.gen(function* () { let sequence = 0; const cloneScope = yield* Scope.make("parallel"); yield* Effect.addFinalizer(() => Scope.close(cloneScope, Exit.void)); - // start/cancel/retry/discard mutate the same entry and the same directory; - // one at a time keeps a double-clicked Retry from racing two clones into it. + // Serialize actions on the shared entry and directory so retries cannot race clones. const actionLock = yield* Semaphore.make(1); const locked = (effect: Effect.Effect) => actionLock.withPermits(1)(effect); @@ -230,11 +226,8 @@ export const make = Effect.gen(function* () { }, Effect.scoped); /** - * Runs in its own fiber; the tracker records the outcome through `onExit`, - * which still runs when the fiber is interrupted (a `matchCause` handler - * would be skipped). Once git has finished the clone is marked done before - * the post-clone hook runs, so a late Cancel cannot tear down a complete - * checkout. + * `onExit` records outcomes even on interruption; `matchCause` would be skipped. + * Mark the clone done before the post-clone hook so a late Cancel cannot remove it. */ const runClone = (projectId: ProjectId, tracked: TrackedClone) => cloneWatchingForStall(projectId, tracked).pipe( @@ -313,14 +306,11 @@ export const make = Effect.gen(function* () { detail: "A clone into this destination is already in progress.", }); } - // Everything after the claim runs to completion even if the requesting - // connection drops: a claimed entry with no fiber could neither be - // cancelled nor retried. Any failure in here releases the claim. + // Finish or release the claim despite disconnects; a claim without a fiber cannot be cancelled or retried. yield* Effect.uninterruptible( Effect.gen(function* () { yield* clearRetention(input.projectId); - // The entry is registered before the project exists so a - // thread.create or project.delete racing this call already sees it. + // Register before creation so racing thread.create and project.delete see the clone. yield* hooks.createProject({ projectId: input.projectId, title: input.title, @@ -348,8 +338,7 @@ export const make = Effect.gen(function* () { const current = yield* Ref.get(clones); const tracked = current.get(projectId); if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; - // Uninterruptible past this point: a client that disconnects mid-cancel - // must not leave a dead fiber behind a "running" snapshot. + // Finish cancellation despite disconnects to avoid dead fibers behind running snapshots. yield* Effect.uninterruptible( Effect.gen(function* () { yield* Fiber.interrupt(tracked.fiber!); @@ -428,11 +417,20 @@ export const make = Effect.gen(function* () { { bufferSize: 1, strategy: "sliding" }, ); + // Forwarders retain the underlying operation spans. return ProjectCloneTracker.of({ - start: (input, hooks) => locked(start(input, hooks)), - cancel: (projectId) => locked(cancel(projectId)), - retry: (projectId) => locked(retry(projectId)), - discard: (projectId) => locked(discard(projectId)), + start: Effect.fnUntraced(function* (input, hooks) { + return yield* locked(start(input, hooks)); + }), + cancel: Effect.fnUntraced(function* (projectId) { + return yield* locked(cancel(projectId)); + }), + retry: Effect.fnUntraced(function* (projectId) { + return yield* locked(retry(projectId)); + }), + discard: Effect.fnUntraced(function* (projectId) { + return yield* locked(discard(projectId)); + }), get, stream, }); diff --git a/apps/server/src/project/ProjectEnrichmentService.test.ts b/apps/server/src/project/ProjectEnrichmentService.test.ts index 492d1c43419ab..6da9ae31203bf 100644 --- a/apps/server/src/project/ProjectEnrichmentService.test.ts +++ b/apps/server/src/project/ProjectEnrichmentService.test.ts @@ -25,12 +25,16 @@ const waitForAvailable = Effect.fn("ProjectEnrichmentServiceTest.waitForAvailabl workspaceRoot: string, predicate: (value: ProjectEnrichment.ProjectEnrichment) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const available = yield* service.peek(workspaceRoot); - if (predicate(available)) return available; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project metadata for ${workspaceRoot} was not resolved in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const changes = yield* service.subscribeChanges; + for (;;) { + const available = yield* service.peek(workspaceRoot); + if (predicate(available)) return available; + yield* PubSub.take(changes); + } + }), + ); }); const layer = ( diff --git a/apps/server/src/project/ProjectEnrichmentService.ts b/apps/server/src/project/ProjectEnrichmentService.ts index 1592204816fec..cbee7275b123f 100644 --- a/apps/server/src/project/ProjectEnrichmentService.ts +++ b/apps/server/src/project/ProjectEnrichmentService.ts @@ -19,9 +19,7 @@ const DEFAULT_CACHE_CAPACITY = 512; const DEFAULT_MAX_PENDING = 512; const DEFAULT_CONCURRENCY = 4; const DEFAULT_SUCCESS_TTL = Duration.minutes(1); -// A favicon scan checks about 20 files. Shell reads (and PR sync through them) ask for every -// linked project about once a minute, and icons rarely change, so a resolved favicon is kept -// longer than the identity, which is only a hit on RepositoryIdentityResolver's own cache. +// Frequent shell reads scan ~20 files per project; icons change rarely, so cache longer than identities. const DEFAULT_FAVICON_TTL = Duration.minutes(15); const DEFAULT_FAILURE_TTL = Duration.seconds(5); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index e42cb5fbe9193..dfae0d5fbe1b5 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -71,9 +71,7 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; -// The tag pattern is anchored on `]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; const ICON_REL_RE = /\brel\s*:\s*["'](?:icon|shortcut icon)["']/i; @@ -235,11 +233,8 @@ export const make = Effect.gen(function* () { return yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); }); - // Starts every probe at once and returns the first hit in list order. A miss - // costs one round of filesystem latency instead of one per probe, which - // matters when the disk is slow. Fibers are joined in order, so a hit returns - // without waiting for lower-ranked probes (the scope interrupts them), and a - // failure surfaces only when no earlier probe found a file. + // Probe concurrently but join in list order so lower-ranked probes cannot delay a hit. + // A failure surfaces only if no earlier probe found a file; scope exit interrupts remaining probes. const firstInOrder = Effect.fn("ProjectFaviconResolver.firstInOrder")(function* ( items: ReadonlyArray, probe: (item: A) => Effect.Effect, diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 9347acef12c8f..331332a84bc95 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,3 +1,6 @@ +import * as Context from "effect/Context"; +import * as Queue from "effect/Queue"; +import * as Tracer from "effect/Tracer"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; @@ -5,12 +8,17 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -51,6 +59,35 @@ const layerMetadata = Layer.merge( }), ); +class EnrichmentCompletions extends Context.Service>()( + "t3/project/ProjectService.test/EnrichmentCompletions", +) {} + +const layerEnrichmentCompletions = Layer.effectContext( + Effect.gen(function* () { + const completions = yield* Queue.unbounded(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if ( + span.name === "ProjectEnrichmentService.resolveRepositoryIdentity" || + span.name === "ProjectEnrichmentService.resolveFavicon" + ) { + Queue.offerUnsafe(completions, undefined); + } + }; + return span; + }, + }); + return Context.make(EnrichmentCompletions, completions).pipe( + Context.add(Tracer.Tracer, tracer), + ); + }), +); + const layerTestFor = ( projectMetadataLayer: Layer.Layer< | ProjectFaviconResolver.ProjectFaviconResolver @@ -65,6 +102,7 @@ const layerTestFor = ( Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })), Layer.provide(NodeServices.layer), + Layer.provideMerge(layerEnrichmentCompletions), ); const layerTest = layerTestFor(layerMetadata); @@ -91,12 +129,16 @@ const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* projectId: ProjectId, predicate: (project: Project) => boolean, ) { - for (let attempt = 0; attempt < 100; attempt += 1) { - const project = Option.getOrThrow(yield* service.getById(projectId)); - if (predicate(project)) return project; - yield* Effect.yieldNow; - } - return yield* Effect.die(`Project ${projectId} was not enriched in time.`); + return yield* Effect.scoped( + Effect.gen(function* () { + const completions = yield* EnrichmentCompletions; + for (;;) { + const project = Option.getOrThrow(yield* service.getById(projectId)); + if (predicate(project)) return project; + yield* Queue.take(completions); + } + }), + ); }); it.layer(layerTest)("ProjectService", (it) => { @@ -203,6 +245,33 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); + it.effect("records the outcome and duration of a committed project command", () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "project.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "project.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* service.create({ + commandId: CommandId.make("command:metrics:create"), + projectId: ProjectId.make("project:metrics"), + title: "Metrics", + workspaceRoot: "/work/metrics", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }), + ); + it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 1cfb2e093340b..5c646e1ea7be1 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -266,8 +267,7 @@ export const make = Effect.gen(function* () { `Command ${command.commandId} was already used by ${receipt.commandType} for ${receipt.projectId}.`, ); } - // A retried command re-plans against the state it already produced, so its - // first receipt, not the new plan, decides the outcome. + // Retries re-plan against their own effects; the first receipt determines the outcome. if (receipt.status === "accepted") return; const rejection = Option.getOrUndefined(decodeProjectCommandRejection(receipt.error)); switch (rejection?._tag) { @@ -284,7 +284,7 @@ export const make = Effect.gen(function* () { rejection ?? receipt.error ?? "The command was previously rejected.", ); } - }); + }, withCommandMetrics); const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) { const row = yield* readRow(projectId, { includeDeleted: true }); @@ -474,11 +474,7 @@ export const make = Effect.gen(function* () { ); }); - // A machine-disabled provider instance that a deleted project's override - // once turned on must not stay probed and alive forever. Nothing else - // removes the entry, so deletion is where it goes; best-effort because a - // settings write failure should not block the deletion it is cleaning up - // after, and a server crash before a replay never revisits this project. + // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion. const clearProjectSettingsOverrides = (projectId: ProjectId) => serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( Effect.asVoid, @@ -493,8 +489,7 @@ export const make = Effect.gen(function* () { const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")( function* (input) { const { projectId } = input; - // A deleted row still reaches commit, so a retried command id replays its - // receipt and any other command id is rejected as not found. + // Read deleted rows so retries replay receipts and other command ids fail as not found. const existing = yield* readRow(projectId, { includeDeleted: true }); if (Option.isNone(existing)) { return yield* new ProjectNotFoundError({ projectId }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000000..c295a06ac39ac --- /dev/null +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,168 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import { ProjectId, type TerminalEvent, type TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import { TestClock } from "effect/testing"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; + +const threadId = "thread-1"; +const terminalId = "setup-setup"; +const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" }; + +const openSnapshot = (history: string): TerminalSessionSnapshot => ({ + threadId, + terminalId, + cwd: "/repo/.t3/worktrees/feature", + worktreePath: null, + status: "running", + pid: 123, + history, + exitCode: null, + exitSignal: null, + label: "zsh", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +const makeHarness = Effect.fn(function* (options: { + readonly history?: string; + readonly opening?: Deferred.Deferred; +}) { + const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const writes = yield* Queue.unbounded(); + const outputLines = yield* Queue.unbounded(); + const terminals = Layer.mock(TerminalManager.TerminalManager)({ + open: () => + (options.opening + ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void + ).pipe(Effect.as(openSnapshot(options.history ?? ""))), + subscribe: (listener) => + Effect.sync(() => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid), + closeIdle: () => Effect.void, + }); + const runner = yield* ProjectSetupScriptRunner.make.pipe( + Effect.provide( + Layer.mergeAll( + terminals, + Layer.mock(ProjectService.ProjectService)({}), + ServerSettings.layerTest(), + NodeCrypto.layer, + ), + ), + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, { SHELL: "/bin/zsh" }), + ); + const start = runner.runForThread({ + threadId, + worktreePath: "/repo/.t3/worktrees/feature", + observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) }, + project: { + id: ProjectId.make("project-1"), + workspaceRoot: "/repo", + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp i", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }, + }); + const emit = (terminalEvent: TerminalEvent) => + Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true }); + return { start, listeners, writes, outputLines, emit }; +}); + +const setup = Effect.fn(function* (options: { readonly history?: string } = {}) { + const harness = yield* makeHarness(options); + const result = yield* harness.start; + const completion = result.status === "started" ? result.completion : undefined; + if (!completion) return yield* Effect.die("setup did not start an observed run"); + return { ...harness, result, completion }; +}); + +describe("ProjectSetupScriptRunner", () => { + it.effect("shows a startup prompt and types only once the line editor starts", () => + Effect.gen(function* () { + const { result, writes, outputLines, emit } = yield* setup(); + expect(result).toMatchObject({ status: "started", terminalId }); + + yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]); + + yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); + yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("types into a quiet shell that has no line editor", () => + Effect.gen(function* () { + const { writes } = yield* setup(); + yield* TestClock.adjust("3 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("ignores line editor state restored from an earlier shell", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" }); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); + + it.effect("forgets a busy startup child when the shell restarts", () => + Effect.gen(function* () { + const { writes, emit } = yield* setup(); + yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); + // The restart lands inside a quiet window, so the next full window decides. + yield* TestClock.adjust("6 seconds"); + expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + }), + ); + + it.effect("drops its terminal listeners when cancelled while the terminal opens", () => + Effect.gen(function* () { + const opening = yield* Deferred.make(); + const { start, listeners } = yield* makeHarness({ opening }); + const fiber = yield* Effect.forkChild(start); + yield* Deferred.await(opening); + expect(listeners.size).toBe(2); + yield* Fiber.interrupt(fiber); + expect(listeners.size).toBe(0); + }), + ); + + it.effect("does not type into a shell that exits during startup", () => + Effect.gen(function* () { + const { completion, writes, emit } = yield* setup(); + yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null }); + expect(yield* completion).toMatchObject({ exitCode: null }); + yield* TestClock.adjust("1 minute"); + expect(yield* Queue.size(writes)).toBe(0); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 6799be186d1a4..612bb773d0748 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -33,9 +33,7 @@ export interface ProjectSetupScriptRunnerResultStarted { readonly terminalId: string; readonly cwd: string; readonly async: boolean; - /** - * The exit code is null when the terminal exited or was closed before the sentinel arrived. - */ + /** Null exit code means the terminal exited or closed before the sentinel. */ readonly completion?: Effect.Effect; } @@ -76,7 +74,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedError()("t3/project/ProjectSetupScriptRunner") {} -/** @public Service construction is part of the canonical Effect module API. */ -/** - * Each run gets its own random token so script output cannot spoof completion, and the sentinel - * pattern is built per run from it. - */ +/** @public */ +// Random per-run tokens prevent script output from spoofing completion. const COMPLETION_SENTINEL_PREFIX = "__T3_SETUP_DONE__"; const OUTPUT_LINE_MAX_LENGTH = 400; const PARTIAL_LINE_MAX_LENGTH = 4_096; @@ -132,16 +127,24 @@ function completionSentinelPattern(token: string): RegExp { } function stripTerminalControl(text: string): string { - return ( - text - .replace( - // eslint-disable-next-line no-control-regex -- ANSI escape sequences start with ESC. - /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, - "", - ) - // eslint-disable-next-line no-control-regex -- removing control characters is the point. - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - ); + return text + .replace( + /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][A-Za-z0-9]|\x1b[=>]/g, + "", + ) + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); +} + +// Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command. +const BRACKETED_PASTE_ON = "\x1b[?2004h"; +const BRACKETED_PASTE_OFF = "\x1b[?2004l"; +// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first. +const SHELL_QUIET_FALLBACK = "3 seconds"; + +function lineEditorReading(text: string, previous: boolean): boolean { + const on = text.lastIndexOf(BRACKETED_PASTE_ON); + const off = text.lastIndexOf(BRACKETED_PASTE_OFF); + return on === off ? previous : on > off; } type CompletionShell = "posix" | "fish" | "powershell"; @@ -158,12 +161,7 @@ function resolveCompletionShell( return "posix"; } -/** - * The command runs inside a block and the block closes on its own line, so a trailing `# comment` - * or a heredoc terminator in the command cannot swallow the sentinel. The shell reads the whole - * block before running any of it, so a script that reads stdin cannot consume the sentinel line - * either. - */ +// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -180,12 +178,13 @@ function wrapCommandForCompletion( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const terminalManager = yield* TerminalManager.TerminalManager; const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Effect.scope; const completionShell = resolveCompletionShell( yield* HostProcess.Platform, yield* HostProcess.Environment, @@ -203,8 +202,7 @@ export const make = Effect.gen(function* () { }) { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); - // The shell redraws its prompt just after the sentinel. Closing before - // that would read the redraw as new activity and keep an idle shell. + // Wait for the prompt redraw; closing earlier mistakes it for activity and preserves an idle shell. const promptReturned = yield* Deferred.make(); let lineBuffer = ""; let settled = false; @@ -246,13 +244,10 @@ export const make = Effect.gen(function* () { } if (event.type === "output") { lineBuffer += event.data; - // A bare carriage return is how installers redraw a progress line in - // place; each redraw becomes a short line of its own instead of being - // glued into one long one. + // Bare CR redraws progress in place; split redraws instead of growing one line. const lines = lineBuffer.split(/\r\n|\r|\n/); lineBuffer = lines.pop() ?? ""; - // A script that never prints a newline must not grow this forever. - // The sentinel is always on its own line, so keeping the tail is safe. + // Bound newline-free output; the sentinel has its own line, so retaining the tail is safe. if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); } @@ -277,7 +272,67 @@ export const make = Effect.gen(function* () { Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))), Effect.ensuring(Effect.sync(() => unsubscribe())), ); - return { completion, unsubscribe }; + // A startup prompt waits for input on a line that has no newline yet. + const flushPartialLine = Effect.suspend(() => { + const partial = lineBuffer; + lineBuffer = ""; + return handleLine(partial); + }); + const abandon = settle(null).pipe( + Effect.andThen(Deferred.succeed(promptReturned, undefined)), + ); + return { completion, unsubscribe, flushPartialLine, abandon }; + }, + ); + + // Input typed during shell startup can be read by an rc-file prompt (fnm, nvm) instead of the + // shell, which loses the command. Subscribe before the terminal opens so no activity is missed. + const watchShellReadiness = Effect.fn("ProjectSetupScriptRunner.watchShellReadiness")( + function* (input: { + readonly threadId: string; + readonly terminalId: string; + readonly onStartupPrompt: Effect.Effect; + }) { + const outcome = yield* Deferred.make<"ready" | "exited">(); + let reading = false; + let carry = ""; + let busy = false; + let activity = 0; + const unsubscribe = yield* terminalManager.subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + activity += 1; + // A new shell process starts idle; the manager resets its subprocess state without an event. + if (event.type === "started" || event.type === "restarted") { + busy = false; + reading = false; + carry = ""; + } + if (event.type === "activity") busy = event.hasRunningSubprocess; + if (event.type === "output") { + const text = carry + event.data; + carry = text.slice(-(BRACKETED_PASTE_ON.length - 1)); + reading = lineEditorReading(text, reading); + } + if (event.type === "exited" || event.type === "closed") { + return Deferred.succeed(outcome, "exited").pipe(Effect.asVoid); + } + return reading ? Deferred.succeed(outcome, "ready").pipe(Effect.asVoid) : Effect.void; + }); + // A startup prompt runs as a child process, so a busy shell keeps waiting for the user. + const quiet = Effect.gen(function* () { + while (true) { + const seen = activity; + yield* Effect.sleep(SHELL_QUIET_FALLBACK); + if (activity !== seen) continue; + if (!busy) return "ready" as const; + yield* input.onStartupPrompt; + } + }); + // Restored history can hold a previous shell's line editor state, so only live output counts. + const wait = Deferred.await(outcome).pipe(Effect.raceFirst(quiet)); + return { wait, unsubscribe }; }, ); @@ -347,8 +402,7 @@ export const make = Effect.gen(function* () { } as const; } - // A thread settles again after it is resumed, and an earlier settle shell - // may still be busy; typing into it would feed its foreground program. + // Earlier settle shells may still run foreground programs; do not type resumed settles into them. const terminalId = input.preferredTerminalId ?? (trigger === "settle" @@ -360,8 +414,7 @@ export const make = Effect.gen(function* () { project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, }), - // Setup can run before a client attaches. Truecolor probes in tools such - // as Vite+ wait for terminal replies that nobody can send at that point. + // Before client attachment, truecolor probes wait for terminal replies nobody can send. COLORTERM: "", }; const observe = input.observeCompletion; @@ -377,25 +430,7 @@ export const make = Effect.gen(function* () { ) : script.command; - yield* terminalManager - .open({ - threadId: input.threadId, - terminalId, - cwd, - worktreePath: input.worktreePath, - env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, - }) - .pipe( - Effect.mapError( - (cause) => - new ProjectSetupScriptOperationError({ - ...errorContext, - operation: "openTerminal", - cause, - }), - ), - ); - // Subscribe before writing so the sentinel cannot race past the listener. + // Subscribe before opening so neither startup activity nor the sentinel can race the listeners. const observed = observe && completionToken ? yield* observeTerminalCompletion({ @@ -407,25 +442,63 @@ export const make = Effect.gen(function* () { onOutputLine: observe.onOutputLine, }) : undefined; + // PowerShell has no line-editor signal; keep its immediate write. + const readiness = + completionShell === "powershell" + ? undefined + : yield* watchShellReadiness({ + threadId: input.threadId, + terminalId, + onStartupPrompt: observed?.flushPartialLine ?? Effect.void, + }); yield* terminalManager - .write({ + .open({ threadId: input.threadId, terminalId, - data: projectScriptTerminalInput(commandLine), + cwd, + worktreePath: input.worktreePath, + env: { ...env, NO_COLOR: "1", FORCE_COLOR: "0" }, }) .pipe( Effect.mapError( (cause) => new ProjectSetupScriptOperationError({ ...errorContext, - operation: "writeCommand", + operation: "openTerminal", cause, }), ), - Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), + Effect.onError(() => + Effect.sync(() => { + observed?.unsubscribe(); + readiness?.unsubscribe(); + }), + ), ); + // Return once the terminal exists so callers can show it while a startup prompt waits. + yield* (readiness?.wait ?? Effect.succeed("ready" as const)).pipe( + Effect.flatMap((shell) => + shell === "exited" + ? Effect.void + : terminalManager.write({ + threadId: input.threadId, + terminalId, + data: projectScriptTerminalInput(commandLine), + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("project script command was not written", { + ...errorContext, + terminalId, + cause, + }).pipe(Effect.andThen(observed?.abandon ?? Effect.void)), + ), + Effect.ensuring(Effect.sync(() => readiness?.unsubscribe())), + Effect.forkIn(serviceScope), + ); + const completion = observed?.completion.pipe( Effect.tap(({ exitCode }) => exitCode === 0 diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 6fc57297a40e0..7cd1cc82d6fec 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -35,8 +35,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const identity = yield* resolver.resolve(cwd); - // Native realpath, since git reports the long form of a directory the - // temp dir may name by its 8.3 short form on Windows. + // Native realpath matches git's long paths when Windows temp dirs use 8.3 names. const resolvedIdentityRoot = identity?.rootPath === undefined ? "" : NodeFS.realpathSync.native(identity.rootPath); const resolvedCwd = NodeFS.realpathSync.native(cwd); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 0ec948b485fc0..00254ee86781e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,9 +1,5 @@ import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider"; -import { - detectSourceControlProviderFromGitRemoteUrl, - normalizeGitRemoteUrl, -} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -13,13 +9,12 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { explicitBareRepositoryEnv } from "../vcs/VcsProcess.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; -// Background sweeps resolve every project each minute. A long TTL keeps them -// from spawning git each time. Clone, publish, and PR discovery (after a turn -// and before it saves links) resolve with `refresh: true`. +// Minute-by-minute sweeps need cached hits; clone, publish and PR discovery force refresh. const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(15); const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); @@ -42,8 +37,7 @@ export class RepositoryIdentityResolver extends Context.Service< } >()("t3/project/RepositoryIdentityResolver") {} -// Local remotes (GitButler's `gb-local` is `.`) name a path on this machine, -// not a repository, so unrelated repos would share one identity. +// Local paths such as GitButler's gb-local=. would give unrelated repos the same identity. function isLocalRemoteUrl(url: string): boolean { if (/^file:/i.test(url)) return true; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false; @@ -102,7 +96,7 @@ function buildRepositoryOrigin( canonicalKey: string, ): RepositoryIdentity["origin"] { if (!originUrl) return undefined; - const originKey = normalizeGitRemoteUrl(originUrl); + const originKey = sourceControlDefinitions.repositoryKeyFromRemote(originUrl); if (originKey === canonicalKey) return undefined; const displayName = repositoryPathOf(originKey, originUrl); return { canonicalKey: originKey, remoteUrl: originUrl, ...(displayName ? { displayName } : {}) }; @@ -114,8 +108,8 @@ function buildRepositoryIdentity(input: { readonly originUrl: string | undefined; readonly rootPath: string; }): RepositoryIdentity { - const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); + const canonicalKey = sourceControlDefinitions.repositoryKeyFromRemote(input.remoteUrl); + const sourceControlProvider = sourceControlDefinitions.recognizeRemote(input.remoteUrl); const repositoryPath = repositoryPathOf(canonicalKey, input.remoteUrl); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; @@ -148,8 +142,7 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. const processRunner = yield* ProcessRunner.ProcessRunner; const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); - // git is a real executable on every platform — no cmd.exe shell mode, which - // would split paths containing spaces during cmd's re-tokenization. + // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization. const topLevelResult = yield* processRunner .run({ command: "git", diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index b4335cb5660ce..1cc008d07cc83 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -28,10 +28,7 @@ export class T3ProjectFileLoadError extends Schema.TaggedError Effect.Effect>; } >()("t3/project/T3ProjectFileLoader") {} @@ -46,7 +43,7 @@ const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/project/WorktreeSetupTracker.ts b/apps/server/src/project/WorktreeSetupTracker.ts index 0bdf7af4d2b05..ff5580961d977 100644 --- a/apps/server/src/project/WorktreeSetupTracker.ts +++ b/apps/server/src/project/WorktreeSetupTracker.ts @@ -56,10 +56,7 @@ export class WorktreeSetupTracker extends Context.Service< phase: "done" | "failed" | "cancelled", error?: string | null, ) => Effect.Effect; - /** - * Called right before the turn is dispatched so a late cancel cannot roll - * back a thread whose agent has already started. - */ + /** Call just before dispatch so late cancellation cannot roll back a started agent's thread. */ readonly markUncancellable: (threadId: ThreadId) => Effect.Effect; readonly cancel: (threadId: ThreadId) => Effect.Effect; readonly get: (threadId: ThreadId) => Effect.Effect; @@ -103,8 +100,7 @@ export const make = Effect.gen(function* () { readonly snapshot: WorktreeSetupSnapshot | null; }>(); const retentionFibers = new Map>(); - // Sequences keep increasing across setups of the same thread so a stream - // opened during a previous setup still accepts the next one's first snapshot. + // Keep sequences increasing across setups so existing subscribers accept the next setup's first snapshot. const lastSequenceByThread = new Map(); const publish = (threadId: ThreadId, snapshot: WorktreeSetupSnapshot | null) => @@ -142,8 +138,7 @@ export const make = Effect.gen(function* () { return next; }).pipe( Effect.andThen(publish(threadId, null)), - // A subscriber that outlives retention sees `null` here and accepts any - // sequence after it, so the counter can start over for this thread. + // After retention, subscribers see null and accept any sequence; the counter can reset. Effect.tap(() => Effect.sync(() => lastSequenceByThread.delete(threadId))), ); @@ -300,19 +295,14 @@ export const make = Effect.gen(function* () { const get: WorktreeSetupTracker["Service"]["get"] = (threadId) => Ref.get(setups).pipe(Effect.map((current) => current.get(threadId)?.snapshot ?? null)); - /** - * Each subscriber gets a one-slot sliding mailbox: a slow WebSocket only ever - * holds the newest snapshot, so a chatty setup script cannot grow the server - * heap. - */ + // One-slot sliding mailboxes bound slow WebSockets to the newest setup snapshot. const stream: WorktreeSetupTracker["Service"]["stream"] = (threadId) => Stream.callback( (mailbox) => Effect.gen(function* () { const subscription = yield* PubSub.subscribe(changes); const initial = yield* get(threadId); - // Changes published between subscribing and reading `initial` are - // already folded into it. Drop them so the client never steps back. + // The initial snapshot already includes queued changes; drop them to avoid stale replay. let lastSequence = initial?.sequence ?? -1; Queue.offerUnsafe(mailbox, initial); yield* Stream.fromSubscription(subscription).pipe( diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index 1a65c11d3cfe0..f1d75b532bc7a 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -18,10 +18,7 @@ const STAGE_PREFIXES: ReadonlyArray = [ const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; -/** - * Git redraws each counter with a bare `\r`, so callers hand over each redraw - * as its own line. - */ +/** Pass each bare-CR progress redraw as a separate line. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { const trimmed = line.trim(); const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed)); diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index dfcfb2fecdc8c..af156a61663f4 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -8,6 +8,7 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as AcpErrors from "effect-acp/errors"; import type * as AcpSchema from "effect-acp/compat"; @@ -62,6 +63,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( const authenticated = yield* Deferred.make(); const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); + const authenticateCalled = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: | ((url: string) => Effect.Effect) @@ -92,6 +94,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( start: () => Effect.gen(function* () { events.push("authenticate"); + yield* Deferred.succeed(authenticateCalled, undefined); if (options.interactive !== false && input.onAuthorizationUrl) { for (const url of options.authorizationUrls ?? [authorizationUrl]) { yield* input.onAuthorizationUrl(url); @@ -129,6 +132,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( authenticated, discovered, closed, + authenticateCalled, events, catalog: () => catalog, forwarded: () => forwarded, @@ -254,6 +258,38 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect("tells the user when the agent cannot reach Google", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ interactive: false }); + yield* harness.auth.controller.start(owner); + yield* Deferred.await(harness.authenticateCalled); + + yield* TestClock.adjust("30 seconds"); + const stalled = yield* harness.auth.controller.subscribe(owner).pipe( + Stream.filter((state) => state.message?.includes("Broken IPv6") === true), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(stalled.phase, "starting"); + + yield* TestClock.adjust("270 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.include(failed.message ?? "", "could not reach Google"); + }), + ); + + it.effect("keeps the expired message once the Google link was shown", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + + yield* TestClock.adjust("300 seconds"); + const failed = yield* phase(harness.auth, "failed"); + assert.equal(failed.message, "Google sign-in expired. Start sign-in again."); + }), + ); + it.effect("accepts direct local or cached completion without a callback RPC", () => Effect.gen(function* () { const harness = yield* makeHarness({ interactive: false }); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c97aa949aab80..67f25681a1a4b 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -31,6 +31,13 @@ import { import type { ProviderAuthController } from "./ProviderAuthService.ts"; const AUTH_TIMEOUT_MS = 300_000; +// A healthy agent shows the Google link or finishes within seconds. Its bundled Python has no +// IPv4 fallback race, so a host with broken IPv6 waits about two minutes per Google request. +const NO_RESPONSE_HINT_MS = 30_000; +const NO_RESPONSE_MESSAGE = + "Antigravity has not reached Google after 30 seconds. Check this machine's network connection. Broken IPv6 is a common cause."; +const UNREACHABLE_MESSAGE = + "Antigravity could not reach Google. Check this machine's network connection, then start sign-in again."; const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; const isSetupError = Schema.is(ProviderSetupError); const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); @@ -264,6 +271,17 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < const runSignIn = Effect.fn("AntigravityAuth.runSignIn")( function* (flow: AuthFlow, stopSessions: Effect.Effect) { yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses)); + yield* Effect.sleep(NO_RESPONSE_HINT_MS).pipe( + Effect.andThen( + lock.withPermits(1)( + Effect.gen(function* () { + if (activeFlow !== flow || flow.state.phase !== "starting") return; + yield* publishFlow(flow, { ...flow.state, message: NO_RESPONSE_MESSAGE }); + }), + ), + ), + Effect.forkScoped, + ); const runtime = yield* options.makeRuntime({ onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url), }); @@ -282,13 +300,20 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ); yield* options.onAuthenticated(started, runtime); }, - (effect, flow, stopSessions) => + (effect, flow, _stopSessions) => effect.pipe( Effect.scoped, Effect.timeoutOrElse({ duration: AUTH_TIMEOUT_MS, orElse: () => - Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")), + Effect.fail( + setupError( + "start", + flow.state.phase === "starting" + ? UNREACHABLE_MESSAGE + : "Google sign-in expired. Start sign-in again.", + ), + ), }), Effect.exit, Effect.flatMap((result) => finishFlow(flow, result)), diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index e7bcbe7553f67..363eb4b78858a 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -43,10 +43,7 @@ const zipFixtures = { "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=", }; -// The installation checks POSIX exec bits off the real filesystem unless the -// platform is win32, so a linux platform mock cannot pass on NTFS. Default to -// the host and let the fixture names follow; the suite is about install -// mechanics, which are the same on every platform. +// Linux platform mocks cannot enforce POSIX exec bits on NTFS; use the host platform. const hostPlatform: NodeJS.Platform = HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; const completeArchive = Buffer.from( @@ -300,8 +297,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { it.effect("accepts an encoded Content-Length when the body is compressed", () => Effect.gen(function* () { - // dl.google.com gzips the archive and reports the encoded size. The decoded - // stream is still checked byte for byte and by hash. + // dl.google.com reports the gzip-encoded size; byte and hash checks use the decoded stream. const { installation, validations } = yield* makeHarness({ contentLength: completeArchive.byteLength - 1_000, contentEncoding: "gzip", @@ -573,8 +569,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { }), ); - // Real posix executables in a real temp dir, resolved by a linux-mocked - // PATH walk; a Windows temp path cannot be split on `:`. + // The mocked Linux PATH walk needs POSIX paths; Windows temp paths contain colons. it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "honors explicit paths and reports invalid overrides without falling back", () => diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 3b7cb6f1e18af..3d590abda0bdb 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -373,8 +373,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma profileDirectory, platform, baseEnv: environment, - // The profile is scoped, so it cleans up the unpack; a shallow - // root keeps it under Windows' path limit. + // A shallow scoped profile cleans up unpacked files within Windows' path limit. tempDirectory: profileDirectory, }); const runtime = yield* makeAntigravityAcpRuntime({ @@ -390,9 +389,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ? ChildProcessSpawner.make( Effect.fnUntraced(function* (command) { const child = yield* spawner.spawn(command); - // Stop PyInstaller's tree before ACP closes its pipes. Once - // the launcher exits, taskkill cannot reach its children, - // which keep the executable and unpacked DLLs locked. + // Stop PyInstaller's tree before ACP closes pipes; after launcher exit, taskkill cannot reach children holding DLLs open. yield* Scope.addFinalizer( validationScope, child.kill({ forceKillAfter: "1 second" }).pipe(Effect.orDie), @@ -407,8 +404,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma if ( initialized.agentInfo?.name !== "antigravity-acp" || initialized.agentInfo.version !== expectedVersion || - // Antigravity 1.1.1 can report 2 with the legacy ACP response shape. - // The ACP client chooses the session wire format from that shape. + // Antigravity 1.1.1 may report protocol 2 with legacy ACP; choose wire format from the response shape. (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) || initialized.agentCapabilities?.loadSession !== true || !initialized.agentCapabilities.sessionCapabilities?.resume || @@ -464,7 +460,6 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma { flag: "wx", mode: 0o600 }, ); yield* fs.rename(pointerPath, activePath); - // The pointer commits the install. Later temp cleanup cannot undo it. yield* SubscriptionRef.update( state, (current) => @@ -531,9 +526,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma const response = yield* http .execute(HttpClientRequest.get(asset.url)) .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); - // dl.google.com gzips the zip when the client accepts it, so - // `content-length` is the encoded size. The decoded stream is still - // checked against the pinned byte count and hash below. + // dl.google.com may gzip the zip: content-length is encoded, while byte/hash checks use decoded data. const contentLength = response.headers["content-length"]; const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase(); const identityBody = contentEncoding === undefined || contentEncoding === "identity"; diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 1c1f3d9a56d9c..c6809c3577132 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -160,9 +160,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: initialDraft, authRevision: 0, }); - // Skills the driver discovered on disk per workspace. Session callbacks - // rewrite the workspace entry with native commands and must keep these, or - // the registry drops the suggestions and never re-reads the workspace. + // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), @@ -211,8 +209,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: { ...draft, installed: !missingInstallation, - // Custom launchers have no static version; their lightweight probe - // must not erase the version learned from a real ACP handshake. + // Lightweight custom-launcher probes lack versions; preserve the real handshake's version. version: version === "unknown" ? draft.version : version || draft.version, status: errorMessage ? "error" : authenticated ? "ready" : "warning", checkedAt: updatedAt, diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 80740b0a21d49..c03916564e0f7 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,11 +1,20 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; -import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { + buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeWorkspaceSnapshot, +} from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); +const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings); + it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => { const options = buildClaudeCapabilitiesProbeQueryOptions({ executablePath: "/usr/bin/claude", @@ -20,3 +29,15 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); + +it.effect("reports a deleted worktree's scan as finished, not pending", () => + Effect.gen(function* () { + const snapshot = yield* probeClaudeWorkspaceSnapshot( + yield* decodeClaudeSettings({}), + { skills: [], slashCommands: [] } as unknown as ServerProvider, + "/nonexistent/t3-deleted-worktree", + { HOME: "/nonexistent/t3-home" }, + ); + assert.strictEqual(snapshot.slashCommandsPending, false); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18f3b8508a04a..5bc82a41d4eda 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -168,11 +168,7 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } -/** - * The capability probe resolves for a logged-out CLI, so a completed probe only proves Claude - * Code started. `tokenSource: "none"` is the CLI reporting it found no token at all, and is the - * one shape that disproves authentication. - */ +/** Probe completion only proves Claude started. Only tokenSource: "none" disproves auth. */ function claudeAuthStatus( capabilities: Pick< ClaudeCapabilitiesProbe, @@ -183,16 +179,14 @@ function claudeAuthStatus( return "authenticated"; } if (capabilities.tokenSource !== "none") return "authenticated"; - // An `ANTHROPIC_API_KEY` install reports no token source but is authenticated - // all the same, so the key and account fields still get a say. + // API keys can authenticate with tokenSource: none; also check key and account fields. const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; return hasApiKey || capabilities.email || capabilities.subscriptionType ? "authenticated" : "unauthenticated"; } -// Amazon Bedrock initializes far slower than first-party auth: the SDK boots the Bedrock -// backend and runs the `awsAuthRefresh` credential hook before returning account info. +// Bedrock initialization includes backend startup and awsAuthRefresh, taking longer than first-party auth. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; // Native CLI startup on Windows can exceed the shared probe deadline. const CLAUDE_VERSION_CHECK_TIMEOUT_MS = 20_000; @@ -215,23 +209,17 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { pathToClaudeCodeExecutable: input.executablePath, abortController: input.abortController, settingSources: [...CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES], - // The probe keeps filesystem setting sources for slash-command discovery, - // but must not run the user's hooks: it fires every few minutes, so - // SessionStart hooks would run on every health check. + // Keep settings for slash-command discovery, but disable hooks so health checks do not run SessionStart repeatedly. settings: { disableAllHooks: true }, allowedTools: [], - // Ignore MCP definitions from every filesystem setting source above. The - // SDK combines this empty explicit map with --strict-mcp-config, which - // Claude refuses next to a managed-mcp.json that already excludes them. + // Exclude filesystem MCP servers; omit strict mode when managed-mcp.json already excludes them and forbids the flag. mcpServers: {}, ...(input.managedMcpConfig ? {} : { strictMcpConfig: true }), env: { ...input.environment, - // Connected claude.ai MCP servers are discovered outside filesystem - // config; disable them independently for this health check. + // Disable claude.ai MCP separately; it is discovered outside filesystem config. ENABLE_CLAUDEAI_MCP_SERVERS: "false", - // Skipping it also avoids Claude spawning a Windows `tasklist | findstr` process tree on - // every periodic refresh. + // Skipping this avoids a Windows tasklist | findstr process tree on every refresh. FORCE_CODE_TERMINAL: undefined, CLAUDE_CODE_AUTO_CONNECT_IDE: "0", CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", @@ -252,18 +240,10 @@ export type ClaudeCapabilitiesProbe = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly apiKeySource: string | undefined; - /** - * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth - * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) - * the subscription/token fields are absent and auth is external AWS creds. - */ + /** OAuth applies only to firstParty; Bedrock uses external AWS credentials and omits subscription/token fields. */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; - /** - * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). - */ + /** Undefined means get_usage failed; absent windows on success mean no subscription windows, as with API keys. */ readonly usage?: Pick; readonly usageCheckedAt?: string; }; @@ -340,12 +320,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { }); } -/** - * We pass a never-yielding AsyncIterable as the prompt so that no user message is ever written - * to the subprocess stdin. This means the Claude Code subprocess completes its local - * initialization IPC (returning account info and slash commands) but never starts an API - * request to Anthropic. - */ +/** A never-yielding prompt allows local initialization IPC without sending an Anthropic request. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, @@ -362,9 +337,7 @@ const probeClaudeCapabilities = ( const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. - // oxlint-disable-next-line require-yield + // oxlint-disable-next-line require-yield -- never yields, so no prompt reaches the API prompt: (async function* (): AsyncGenerator { await waitForAbortSignal(abort.signal); })(), @@ -458,10 +431,14 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); - const probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( - Effect.result, - ); - const capabilities = Result.isSuccess(probeResult) ? probeResult.success : undefined; + const fileSystem = yield* FileSystem.FileSystem; + // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. + const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); + const probeResult = cwdExists + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + : undefined; + const capabilities = + probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; return { ...machineSnapshot, skills, @@ -469,7 +446,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), ]), - slashCommandsPending: !capabilities, + slashCommandsPending: cwdExists && !capabilities, }; }); diff --git a/apps/server/src/provider/ClaudeTurnTokenUsage.ts b/apps/server/src/provider/ClaudeTurnTokenUsage.ts index 43b60d21fffa5..89cb3de6ed556 100644 --- a/apps/server/src/provider/ClaudeTurnTokenUsage.ts +++ b/apps/server/src/provider/ClaudeTurnTokenUsage.ts @@ -23,8 +23,7 @@ function addKnownCount(finalCount: unknown, earlierCounts: ReadonlyArray, earlierSegmentUsage: ReadonlyArray, diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 20cc69cac391e..1051f72814390 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -306,8 +306,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* for (const session of "sessions" in saved ? saved.sessions : [saved]) { if (!session.scopes.includes(REQUIRED_SCOPE) || connections.has(session.clientId)) continue; connections.add(session.clientId); - // Subjects are client-scoped. Use verified email only for counting locally, - // never export it or merge the underlying profiles. + // Client-scoped subjects need verified email for local counting only; never export email or merge profiles. const email = session.email?.trim().toLowerCase(); if (email) accounts.add(email); else unidentifiedConnectedConnectionCount++; @@ -460,8 +459,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const savedRegistration = changingAccount ? undefined : registrations.profiles.find((profile) => profile.clientId === selectedClientId); - // Older development registrations used localhost, which cannot be changed on reauth. - // Register a 127.0.0.1 connection instead, preserving the verified account. + // Legacy localhost registrations cannot change on reauth; register 127.0.0.1 while preserving the verified account. const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true; const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId; const selectedTokens = (yield* readSessions).sessions.find( @@ -640,8 +638,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const expectedSubject = savedRegistration?.subject ?? (previous?.clientId === registeredClientId ? previous?.subject : undefined); - // Subjects are checked within the same registration. Legacy callback migration - // registers a new client, whose subject cannot be compared with the old client. + // Compare subjects only within a registration; legacy callback migration creates a new client. if (registeredClientId && expectedSubject && identity.subject !== expectedSubject) return yield* failure( "verify", diff --git a/apps/server/src/provider/CodexChatGptHandoff.ts b/apps/server/src/provider/CodexChatGptHandoff.ts index 0b73ae0cf1ab7..9d00a1b966e50 100644 --- a/apps/server/src/provider/CodexChatGptHandoff.ts +++ b/apps/server/src/provider/CodexChatGptHandoff.ts @@ -18,17 +18,25 @@ export function subscribeChatGptHandoff( const bytes = new Map(); yield* Effect.addFinalizer(() => Effect.sync(() => bytes.clear())); const store = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))), - set: (key, value) => - Effect.sync(() => { + get: Effect.fn("ServerSecretStore.get")(function (key) { + return Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))); + }), + set: Effect.fn("ServerSecretStore.set")(function (key, value) { + return Effect.sync(() => { bytes.set(key, value); - }), - remove: (key) => - Effect.sync(() => { + }); + }), + remove: Effect.fn("ServerSecretStore.remove")(function (key) { + return Effect.sync(() => { bytes.delete(key); - }), - create: () => Effect.die("Handoff does not create persistent secrets."), - getOrCreateRandom: () => Effect.die("Handoff uses the destination environment identity."), + }); + }), + create: Effect.fn("ServerSecretStore.create")(function () { + return Effect.die("Handoff does not create persistent secrets."); + }), + getOrCreateRandom: Effect.fn("ServerSecretStore.getOrCreateRandom")(function () { + return Effect.die("Handoff uses the destination environment identity."); + }), }); const auth = yield* makeCodexChatGptAuth({ ...endpoints, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 55758f7cc40ef..92e242a7ad714 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -30,7 +30,20 @@ describe("buildCodexAdditionalContext", () => { }); }); -describe("buildCodexDeveloperInstructions", () => { +describe("Codex runtime context", () => { + it("describes Markdown media support in the runtime context in both modes", () => { + { + const instructions = runtimeInstructions({ + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + NodeAssert.match( + instructions, + /.*embed images, video, and audio.*Markdown image syntax with absolute file paths.*Link any other file.*absolute path.*files viewer.*<\/runtime_info>/, + ); + } + }); + it("flattens multiline metadata into single-line runtime info", () => { const instructions = runtimeInstructions({ model: "gpt\n5.3\ncodex", @@ -42,6 +55,27 @@ describe("buildCodexDeveloperInstructions", () => { }); }); +describe("Codex orchestration context", () => { + it("routes subagents native first, delegate_task for the rest", () => { + const orchestration = Object.entries(buildCodexAdditionalContext({ model: "gpt-6.1-sol" })) + .filter(([key]) => key.startsWith("t3_code_orchestration_")) + .map(([, entry]) => entry.value) + .join("\n"); + + NodeAssert.ok(orchestration.includes("use your native tool when the child uses your provider")); + NodeAssert.ok(orchestration.includes("`run_in_background:false`")); + NodeAssert.ok(orchestration.includes("A native type running another provider's model")); + NodeAssert.ok( + orchestration.includes("Every new delegate review or re-check is a new `delegate_task`"), + ); + NodeAssert.ok(orchestration.includes("never a `t3_thread_send`")); + NodeAssert.ok(!orchestration.includes("inheritedOptions")); + NodeAssert.ok(!orchestration.includes("Prefer native")); + // The ACP terminal fallback is ACP-only; Codex agents never need it. + NodeAssert.ok(!orchestration.includes("acp-mcp-call")); + }); +}); + function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 5c9f51e55230d..3ec4c33a0a3f1 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,4 +1,3 @@ -import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; @@ -7,16 +6,12 @@ import { T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; -/** - * Codex cuts the middle out of any `additionalContext` value over 1,000 tokens, - * which it counts as UTF-8 bytes divided by four. It does this silently, so - * every value built here has to fit. - */ +/** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices -The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, using the exact launcher path returned by \`device_open\`. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Prefer these tools and \`agent-device\` for opening and driving devices. Platform tools such as \`xcrun simctl\` and \`adb\` remain available for anything they do not cover, such as builds, logs, or port forwarding. If \`device_list\` reports a platform as unavailable, say so.`; +For iOS Simulators and Android Emulators here, call \`device_list\`, then \`device_open\`. Drive the device with the exact \`agent-device\` launcher and flags it returns, on every command. Prefer \`agent-device snapshot -i\` refs over coordinates. Use \`device_screenshot\` to see the screen. Use \`xcrun simctl\` or \`adb\` only for builds, logs, and ports. If a platform is unavailable, say so.`; export interface T3CodeToolAvailability { readonly browser: boolean; @@ -28,12 +23,7 @@ const normalizeAvailability = ( ): T3CodeToolAvailability => typeof availability === "boolean" ? { browser: availability, device: false } : availability; -/** - * Describing `preview_*` or `device_*` tools that aren't in the turn's tool list would be worse - * than saying nothing: the instructions actively steer the model away from Playwright, - * agent-browser, and raw simctl/adb, so leaving them in would talk it out of the only automation - * it still has. - */ +/** Instructions for unavailable tools would steer the model away from its remaining automation. */ const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { const tools = normalizeAvailability(availability); return [ @@ -44,173 +34,19 @@ const toolInstructions = (availability: boolean | T3CodeToolAvailability): strin .join("\n\n"); }; -const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) - -You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. - -## Mode rules (strict) - -You are in **Plan Mode** until a developer message explicitly ends it. - -Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. - -## Plan Mode vs update_plan tool - -Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a \`\` block. - -Separately, \`update_plan\` is a checklist/progress/TODOs tool; it does not enter or exit Plan Mode. Do not confuse it with Plan mode or try to use it while in Plan mode. If you try to use \`update_plan\` in Plan mode, it will return an error. - -## Execution vs. mutation in Plan Mode - -You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. - -### Allowed (non-mutating, plan-improving) - -Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: - -* Reading or searching files, configs, schemas, types, manifests, and docs -* Static analysis, inspection, and repo exploration -* Dry-run style commands when they do not edit repo-tracked files -* Tests, builds, or checks that may write to caches or build artifacts (for example, \`target/\`, \`.cache/\`, or snapshots) so long as they do not edit repo-tracked files - -### Not allowed (mutating, plan-executing) - -Actions that implement the plan or change repo-tracked state. Examples: - -* Editing or writing files -* Running formatters or linters that rewrite files -* Applying patches, migrations, or codegen that updates repo-tracked files -* Side-effectful commands whose purpose is to carry out the plan rather than refine it - -When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. - -## PHASE 1 - Ground in the environment (explore first, ask second) - -Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. - -Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. - -Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. - -Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. - -## PHASE 2 - Intent chat (what they actually want) - -* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. -* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet-ask. - -## PHASE 3 - Implementation chat (what/how we'll build) - -* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. - -## Asking questions - -Critical rules: - -* Strongly prefer using the \`request_user_input\` tool to ask any questions. -* Offer only meaningful multiple-choice options; don't include filler choices that are obviously wrong or irrelevant. -* In rare cases where an unavoidable, important question can't be expressed with reasonable multiple-choice options (due to extreme ambiguity), you may ask it directly without the tool. - -You SHOULD ask many questions, but each question must: - -* materially change the spec/plan, OR -* confirm/lock an assumption, OR -* choose between meaningful tradeoffs. -* not be answerable by non-mutating commands. - -Use the \`request_user_input\` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. - -## Two kinds of unknowns (treat differently) - -1. **Discoverable facts** (repo/system truth): explore first. - - * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). - * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. - * If asking, present concrete candidates (paths/service names) + recommend one. - * Never ask questions you can answer from your environment (e.g., "where is this struct"). - -2. **Preferences/tradeoffs** (not discoverable): ask early. - - * These are intent or implementation preferences that cannot be derived from exploration. - * Provide 2-4 mutually exclusive options + a recommended default. - * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. - -## Finalization rule - -Only output the final plan when it is decision complete and leaves no decisions to the implementer. - -When you present the official plan, wrap it in a \`\` block so the client can render it specially: - -1) The opening tag must be on its own line. -2) Start the plan content on the next line (no text on the same line as the tag). -3) The closing tag must be on its own line. -4) Use Markdown inside the block. -5) Keep the tags exactly as \`\` and \`\` (do not translate or rename them), even if the plan content is in another language. - -Example: - - -plan content - - -plan content should be human and agent digestible. The final plan must be plan-only, concise by default, and include: - -* A clear title -* A brief summary section -* Important changes or additions to public APIs/interfaces/types -* Test cases and scenarios -* Explicit assumptions and defaults chosen where needed - -When possible, prefer a compact structure with 3-5 short sections, usually: Summary, Key Changes or Implementation Changes, Test Plan, and Assumptions. Do not include a separate Scope section unless scope boundaries are genuinely important to avoid mistakes. - -Prefer grouped implementation bullets by subsystem or behavior over file-by-file inventories. Mention files only when needed to disambiguate a non-obvious change, and avoid naming more than 3 paths unless extra specificity is necessary to prevent mistakes. Prefer behavior-level descriptions over symbol-by-symbol removal lists. For v1 feature-addition plans, do not invent detailed schema, validation, precedence, fallback, or wire-shape policy unless the request establishes it or it is needed to prevent a concrete implementation mistake; prefer the intended capability and minimum interface/behavior changes. - -Keep bullets short and avoid explanatory sub-bullets unless they are needed to prevent ambiguity. Prefer the minimum detail needed for implementation safety, not exhaustive coverage. Within each section, compress related changes into a few high-signal bullets and omit branch-by-branch logic, repeated invariants, and long lists of unaffected behavior unless they are necessary to prevent a likely implementation mistake. Avoid repeated repo facts and irrelevant edge-case or rollout detail. For straightforward refactors, keep the plan to a compact summary, key edits, tests, and assumptions. If the user asks for more detail, then expand. - -Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. - -Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. - -If the user stays in Plan mode and asks for revisions after a prior \`\`, any new \`\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`\` unchanged. -`; - -const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default - -You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. - -Your active mode changes only when new developer instructions with a different \`...\` change it; user requests or tool descriptions do not change mode by themselves. Known mode names are Default and Plan. - -## request_user_input availability - -Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. - -In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -`; - export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; } -export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { - return interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; -} - -/** - * This must stay out of the collaboration mode: when the model catalog ships - * its own text for a mode, as newer models do, Codex uses that text and drops - * the client's `developer_instructions` entirely. - */ +/** Keep this outside collaboration mode; model-catalog mode text overrides client developer_instructions. */ export function buildCodexAdditionalContext( runtime: CodexRuntimeInfo, toolsAvailable: boolean | T3CodeToolAvailability = true, ): Record { const tools = toolInstructions(toolsAvailable); - // Separate keys keep each value under Codex's per-entry cap. Codex emits - // entries in key order, so the numbering preserves the authored order. + // Split values to fit per-entry caps; number keys because Codex emits them in key order. return { ...Object.fromEntries( T3_CODE_ORCHESTRATION_SECTIONS.map((section, index) => [ diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts index e481f95e3c578..758eb406f1b54 100644 --- a/apps/server/src/provider/CodexInstallation.ts +++ b/apps/server/src/provider/CodexInstallation.ts @@ -202,7 +202,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function "resolve", "The managed Codex installation record is invalid. Reinstall Codex.", ); - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))( + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))( yield* fs.readFileString(file), ); }); @@ -597,8 +597,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function }), { flag: "wx", mode: 0o600 }, ); - // On Windows, Defender and kernel process rundown can briefly hold the - // verified codex.exe handle open right after `--version` exits. + // Windows Defender or process rundown may retain codex.exe handles briefly after --version exits. const renameRuntime = fs.rename(runtime, destination); yield* platform === "win32" ? renameRuntime.pipe(Effect.retry({ times: 10, schedule: Schedule.spaced("100 millis") })) @@ -741,9 +740,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function ); yield* Effect.gen(function* () { - // Two servers can share a home, and writes below a staging directory do not - // refresh its mtime, so only directories far older than any real install - // (download is capped at 45 minutes) are treated as abandoned. + // Shared-home installs do not refresh parent mtimes; remove only staging directories far older than the 45-minute download cap. let canRemove = false; if (yield* fs.exists(managedDirectory)) { const entries = yield* fs @@ -762,8 +759,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function if (now - modifiedAt < STAGING_STALE_AFTER_MS) continue; yield* fs.remove(target, { recursive: true, force: true }).pipe(Effect.ignore); } - // The empty root itself stays: deleting it could race another server - // creating a staging directory inside. An unreadable root stays removable. + // Keep the empty root to avoid racing new staging directories; unreadable roots remain removable. const remaining = yield* fs.readDirectory(managedDirectory).pipe(Effect.option); canRemove = Option.isNone(remaining) || remaining.value.length > 0; } diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index d064dd708ad97..8458315f64de8 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -71,10 +71,7 @@ const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; const CODEX_CLIENT_NAME = "T3 Code"; -// Codex's probe does a full handshake plus account, models, and skills requests, which can -// legitimately run past the shared 10s AUTH_PROBE_TIMEOUT_MS on a loaded or slow machine -// (most often reported on Windows). Give Codex its own longer budget instead of raising the -// timeout for every provider. +// Handshake plus account/models/skills requests can exceed the shared 10s probe budget. const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000; const CODEX_DISCOVERY_MARGIN_MS = 250; @@ -440,12 +437,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio ); }); -/** - * App-server starts its catalog refresh before `initialize` names the client, - * so that first fetch goes out as the plain CLI, misses tiers like Sol - * Ultrafast, and overwrites the shared `models_cache.json`. Naming the - * originator at spawn closes that race. - */ +/** Set originator at spawn; the pre-initialize refresh otherwise overwrites shared cache with plain-CLI models. */ export const codexAppServerEnvironment = ( environment: NodeJS.ProcessEnv | undefined, ): NodeJS.ProcessEnv => ({ @@ -473,9 +465,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { - // `~` is not shell-expanded when env vars are set via `child_process.spawn`, - // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip - // "CODEX_HOME points to '~/.codex_work', but that path does not exist". + // Expand CODEX_HOME explicitly: spawn passes a literal ~ that Codex rejects. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory) : undefined; @@ -566,7 +556,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. input.skipNativeUsage - ? Effect.succeed(undefined) + ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + Effect.succeed(undefined) : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), deadlineMs, diff --git a/apps/server/src/provider/CodexTurnTokenUsage.ts b/apps/server/src/provider/CodexTurnTokenUsage.ts index 6f4959627eff9..7b0f20a123ffe 100644 --- a/apps/server/src/provider/CodexTurnTokenUsage.ts +++ b/apps/server/src/provider/CodexTurnTokenUsage.ts @@ -66,10 +66,7 @@ export function getCodexTurnAccumulator( return created; } -/** - * Codex reports a running `total` for the thread and `last`, the usage of the - * newest model response. - */ +/** total is thread-wide; last covers the newest model response. */ function codexTurnTokenUsageDelta( previous: CodexCumulativeTokenUsage | undefined, current: CodexCumulativeTokenUsage, @@ -104,8 +101,7 @@ export function accumulateCodexTurnTokenUsage( ): void { const current = codexTokenUsageBreakdown(usage.total); if (state.activeTurnId !== turnId) { - // The total is thread-wide, so every update moves the baseline. A late - // update for a finished turn is not counted toward the live turn. + // Every thread-wide total updates the baseline; late finished-turn updates do not count toward the live turn. state.baseline = current; return; } diff --git a/apps/server/src/provider/DocumentText.ts b/apps/server/src/provider/DocumentText.ts new file mode 100644 index 0000000000000..83aa014257ae6 --- /dev/null +++ b/apps/server/src/provider/DocumentText.ts @@ -0,0 +1,120 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the isolated parser process is an external runtime boundary. +import * as NodeChildProcess from "node:child_process"; +import * as NodeURL from "node:url"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +const extensions = new Set(["docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub", "ipynb"]); + +class DocumentTextError extends Schema.TaggedError()("DocumentTextError", { + operation: Schema.Literal("extract"), + path: Schema.String, + extension: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} ${this.extension} document text.`; + } +} + +export const resolveExtractedTextPath = Effect.fn("DocumentText.resolveExtractedTextPath")( + function* (path: string) { + const extension = path.split(".").at(-1)?.toLowerCase(); + if (!extension || !extensions.has(extension)) return; + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path + ".txt").pipe( + Effect.map((exists) => (exists ? path + ".txt" : undefined)), + Effect.orElseSucceed(() => undefined), + ); + }, +); + +export class DocumentText extends Context.Service< + DocumentText, + { + readonly ensure: (path: string) => Effect.Effect; + } +>()("t3/provider/DocumentText") {} + +const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const executable = yield* HostProcess.ExecutablePath; + const isExecutable = yield* HostProcess.IsExecutable; + const environment = yield* HostProcess.Environment; + const clock = yield* Clock.Clock; + const lock = yield* Semaphore.make(1); + const failed = new Map(); + // In a bundle import.meta.url is bin.mjs; development and tests use this source module. + const entry = NodeURL.fileURLToPath( + new URL(import.meta.url.endsWith(".ts") ? "../bin.ts" : "./bin.mjs", import.meta.url), + ); + const extract = Effect.fn("DocumentText.extract")(function* (path: string, extension: string) { + const sidecar = `${path}.txt`; + if (yield* fs.exists(sidecar)) return sidecar; + if ((failed.get(path) ?? 0) > clock.currentTimeMillisUnsafe()) return; + const info = yield* fs.stat(path); + if (Number(info.size) > 16 * 1024 * 1024) return; + const content = yield* Effect.tryPromise({ + try: (signal) => + new Promise((resolve, reject) => { + NodeChildProcess.execFile( + executable, + [...(isExecutable ? [] : [entry]), "__document-text", path, `.${extension}`], + { + signal, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024 + 1024, + encoding: "utf8", + windowsHide: true, + env: { + ...environment, + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: `${environment.NODE_OPTIONS ?? ""} --max-old-space-size=192`, + }, + }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + }), + catch: (cause) => new DocumentTextError({ operation: "extract", path, extension, cause }), + }).pipe( + Effect.tapError(({ cause }) => { + if (failed.size >= 1024) failed.delete(failed.keys().next().value!); + failed.set( + path, + cause instanceof Error && "code" in cause && cause.code === 2 + ? Infinity + : clock.currentTimeMillisUnsafe() + 1_000, + ); + return Effect.logWarning("Document text extraction failed.", { extension }); + }), + ); + const pending = `${sidecar}.part`; + yield* fs + .writeFileString(pending, content) + .pipe( + Effect.andThen(fs.rename(pending, sidecar)), + Effect.ensuring(fs.remove(pending, { force: true }).pipe(Effect.ignore)), + Effect.uninterruptible, + ); + return sidecar; + }, lock.withPermits(1)); + const ensure = Effect.fn("DocumentText.ensure")( + function* (path: string) { + const extension = path.split(".").at(-1)?.toLowerCase(); + if (!extension || !extensions.has(extension)) return; + const sidecar = `${path}.txt`; + if (yield* fs.exists(sidecar)) return sidecar; + return yield* extract(path, extension); + }, + Effect.orElseSucceed(() => undefined), + ); + return DocumentText.of({ ensure }); +}); + +export const layer = Layer.effect(DocumentText, make); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 0f42271c62d22..fe974dee763ff 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -215,11 +215,9 @@ export const AntigravityDriver: ProviderDriver< : cause, ), ); - // Each process unpacks into its own directory that dies with the - // runtime scope, after the child is killed. A shared directory would - // let one session's teardown delete files a sibling still reads. - // Removal is best effort: a handle can outlive the kill on Windows, - // and the sweep on the next driver start reclaims what is left. + // Use one directory per process; shared cleanup could delete files another session still reads. + // Remove after the child dies, best-effort on Windows where handles may outlive the kill; + // the next driver start sweeps leftovers. const runtimeTempDirectory = yield* Effect.acquireRelease( fileSystem.makeTempDirectory({ directory: profile.tempDirectory, prefix: "run-" }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 7b25e24d90817..19e9d8d293f48 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -7,12 +7,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; -/** - * The home directory the agent expands `~` against, matching Python's - * `os.path.expanduser` in the launch environment T3 hands the process: - * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` - * elsewhere. - */ +/** Match Python os.path.expanduser: USERPROFILE, then HOMEDRIVE+HOMEPATH on Windows; HOME elsewhere. */ export function resolveAntigravityUserHome( platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, @@ -28,12 +23,7 @@ export function resolveAntigravityUserHome( return environment.HOME || home; } -/** - * The agent resolves both under `GEMINI_HOME`, which T3 points at a private - * profile, so the profile links these back to the user's `~/.gemini`. - * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a - * project directory. - */ +/** Link private GEMINI_HOME skills to ~/.gemini; ~/.agents/skills is ignored because .agents/skills is project-only. */ export function antigravityUserSkillDirectories( path: Path.Path, geminiHome: string, @@ -147,9 +137,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( return bytes.toString("utf8"); }); -/** - * Read failures remain typed so workspace snapshots do not cache partial results. - */ +/** Keep read errors typed so workspace snapshots cannot cache partial results. */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; readonly userHome: string; diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index e2bf2b99eefa1..5e4cbb4db6efe 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -7,20 +7,13 @@ import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -/** - * Windows launcher-script extensions that Node cannot spawn without a shell - * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore - * cannot use as `pathToClaudeCodeExecutable`. - */ +/** Node 20.12+ cannot spawn Windows launcher scripts without a shell; the SDK cannot use these as executables. */ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ".ps1"]); export const isWindowsClaudeLauncherShimPath = (filePath: string): boolean => WINDOWS_SHIM_EXTENSIONS.has(NodePath.win32.extname(filePath).toLowerCase()); -/** - * Newer package versions ship a native `bin/claude.exe`; older versions only ship `cli.js`, - * which the SDK runs with a JavaScript runtime. - */ +/** New packages provide bin/claude.exe; older packages need a JavaScript runtime for cli.js. */ const NPM_PACKAGE_ENTRY_CANDIDATES = [ ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], @@ -43,12 +36,7 @@ export const ClaudeExecutableFileCheck = Context.Reference( }, ); -/** - * The SDK spawns the given path without a shell and without Windows PATH / PATHEXT resolution, - * so a bare command name like `claude` fails with "native binary not found" and an npm - * `claude.cmd` shim fails with `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, - * which can fall back to `shell: true`; the SDK offers no such escape hatch. - */ +/** On Windows the SDK skips shell and PATH/PATHEXT lookup: bare names and .cmd shims fail. */ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return { const platform = yield* HostProcess.Platform; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 0c7908b0264ca..bd81aa10bce7a 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -37,21 +37,12 @@ export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function const resolvedHomePath = yield* resolveClaudeHomePath(config); return { ...resolvedBaseEnv, - // Overriding HOME also relocates the macOS login keychain lookup - // ($HOME/Library/Keychains), so the spawned CLI can't find its stored - // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points - // Claude Code at its config dir directly while leaving HOME (and the - // keychain) intact. + // Changing HOME hides macOS Keychain credentials; use CLAUDE_CONFIG_DIR to keep login access. CLAUDE_CONFIG_DIR: resolvedHomePath, }; }); -/** - * While it exists Claude refuses `--strict-mcp-config` and exits before - * initializing, but it already ignores user, project and `--mcp-config` servers - * on its own, so callers that pass the flag for isolation can drop it. Paths - * match the ones Claude Code checks. - */ +/** managed-mcp.json excludes other MCP config and forbids --strict-mcp-config; omit the flag when present. */ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { const fileSystem = yield* FileSystem.FileSystem; const platform = yield* HostProcess.Platform; @@ -85,10 +76,7 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC }, ); -/** - * Describe the spawned CLI's environment separately from the login command so - * paths remain literal on every shell, including relative inherited values. - */ +/** Separate spawn env from login commands to preserve literal paths, including relative inherited values. */ export const claudeSignedOutMessage = (input: { readonly configDir: string | undefined; readonly cwd: string; diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 2524dcbb6c7d3..534308e9c2f4f 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -1,18 +1,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; /** - * Claude Code's only user-side invocation is a text block whose first character - * is `/`: the harness expands `/name args` into the SKILL.md body, and every - * character after the name (newlines included) arrives as `ARGUMENTS`. Only one - * skill expands per message; a second `/x` becomes argument text - * ([#87113](https://github.com/anthropics/claude-code/issues/87113)). + * Claude expands one skill per message only when `/name` starts the text block. + * Everything after the name, including newlines or a second `/x`, becomes `ARGUMENTS`. + * https://github.com/anthropics/claude-code/issues/87113 */ -/** - * Same token shape the composer and timeline chips recognise - * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a - * dispatched skill are always the same set. - */ +/** Match packages/shared/src/composerInlineTokens.ts so displayed chips and dispatched skills accept the same tokens. */ const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 62c322cabf120..e331254c11718 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -504,9 +504,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); - // A skill with no frontmatter falls back to its directory name; a skill - // whose frontmatter fails to parse is skipped entirely (Claude Code - // won't load it either). + // Claude falls back to directory names without frontmatter, but skips malformed frontmatter. assert.deepEqual( skills.map((skill) => skill.name), ["no-frontmatter"], @@ -561,8 +559,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const workspace = path.join(tempDir, "workspace"); yield* fs.makeDirectory(workspace, { recursive: true }); - // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own - // cwd (the workspace), so discovery must do the same. + // The CLI resolves relative CLAUDE_CONFIG_DIR against workspace cwd. yield* writeSkill( path.join(workspace, "relative-config", "skills"), "relative-skill", @@ -684,8 +681,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", `name: ${name}`, "---", "", "# Body"].join("\n"), ); } - // Verified against the CLI: with an unknown string or a boolean in the - // map, the valid "off" sibling is ignored too and every skill runs. + // Claude ignores the entire override map when any value is an unknown string or boolean. yield* fs.writeFileString( path.join(configDir, "settings.json"), '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }', diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index ff26287611812..56c2ff19bebdf 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -33,11 +33,7 @@ type SkillFrontmatter = readonly userInvocable?: boolean; }; -/** - * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`, - * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and - * numbers. - */ +/** Claude accepts YAML 1.1 booleans that this YAML 1.2 parser leaves as strings or numbers. */ function parseFrontmatterBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") return value; if (typeof value === "number") { @@ -71,8 +67,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { try { parsed = parseYamlDocument(frontmatter); } catch { - // Claude Code accepts plain scalars containing `: `. Repair only those, - // leaving comments and YAML structure for the full-document parser. + // Repair Claude's accepted ': ' scalars without disturbing comments or YAML structure. const repaired = frontmatter.replace( /^([\w-]+:[ \t]*)([^\r\n]*)/gm, (line, prefix: string, value: string) => { @@ -424,11 +419,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct if (homePath.length > 0) { return path.resolve(expandHomePath(homePath, home)); } - // No tilde expansion here: the spawned CLI receives this env var verbatim - // (env vars are never shell-expanded), so a literal `~` must stay literal - // for discovery to scan the same directory the runtime would. A relative - // value is resolved against the workspace cwd — the subprocess's own cwd — - // for the same reason. + // Match the spawned CLI: preserve a literal ~ and resolve relative config against workspace cwd. const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; if (environmentConfigDir.length > 0) { return cwd ? path.resolve(cwd, environmentConfigDir) : path.resolve(environmentConfigDir); @@ -436,11 +427,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct return path.join(home, ".claude"); }); -/** - * Roots are listed highest precedence first and the first hit for a name wins, - * matching Claude Code: verified against the CLI with the same skill name in - * both scopes, the user copy is the one that runs. - */ +/** First name wins, matching Claude Code's precedence of user skills over project skills. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick & Partial>, cwd?: string, @@ -455,7 +442,6 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* environment ?? process.env, ); - // Use the session's argument parser so discovery sees the same --add-dir value. const additionalDirectory = parseCliArgs(config.launchArgs ?? "").flags["add-dir"]; const roots: ReadonlyArray = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index db8f42faea7f0..90e37cc66712b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -254,12 +254,8 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(restored.runtimePaths?.shadowHomePath).toContain( `providers/codex/${instanceId}/shadow`, ); - // The fixture app-server cannot start, so the workspace scan fails - // instead of passing the machine snapshot off as the workspace's skills. const scan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe(Effect.exit); expect(Exit.isFailure(scan)).toBe(true); - // The account is still signed in, so a runtime that cannot start is a - // failed scan too, not the machine snapshot's inventory. failAcquire = true; const runtimeScan = yield* instance.snapshotForCwd!(serverConfig.stateDir).pipe( Effect.exit, @@ -299,8 +295,6 @@ it.layer(layerTest)("CodexDriver", (it) => { expect(after.installed).toBe(true); expect(after.models).toEqual([]); expect(Option.isNone(yield* store.get)).toBe(true); - // Signed out, there is no runtime to scan with, so the machine - // snapshot's empty inventory stands instead of a failed scan. const signedOutScan = yield* instance.snapshotForCwd!(serverConfig.stateDir); expect(signedOutScan.skills).toEqual([]); }).pipe( @@ -463,9 +457,6 @@ it.layer(layerTest)("CodexDriver", (it) => { ); } - for (const {} of ["direct", "wrapper"] as const) { - } - it.effect.each([ { name: "conventional shim", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5462b13867b71..2d078cf32594f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -64,12 +64,7 @@ function isCodexStandaloneCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/packages/standalone/"); } -/** - * `codex update` replaces the standalone tree under `CODEX_HOME`. That tree - * lives in the shared home even when an auth-overlay shadow home is in use - * (the overlay only carries auth and a few local entries), so the updater - * runs against `sharedHomePath` rather than the instance's effective home. - */ +/** `codex update` owns the shared home's standalone tree; auth-overlay homes do not carry it. */ function makeCodexMaintenanceResolver(sharedHomePath: string) { return makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, @@ -286,13 +281,9 @@ export const CodexDriver: ProviderDriver< ), ); - // Redemption spends something on the user's account. It serialises on - // the account (instances sharing a Codex home share the credit), keeps - // one idempotency key until Codex reports an outcome, and is bounded so - // a hung app-server cannot hold the account lock. - // Keyed on the directory holding auth.json: an auth-overlay instance has - // its own account under `effectiveHomePath`, while plain instances share - // the common home. The continuation key would conflate the two. + // Redemption spends account credit; serialize by the auth.json directory, keep one idempotency key + // until the outcome, and bound the wait so a hung app-server cannot hold the account lock. + // Auth overlays use effectiveHomePath; the continuation key would conflate them with shared homes. const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const consumeResetCredit: NonNullable = () => resetCreditCoordinator diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index b0bb7df4e2d46..f175a0fdd00a9 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -31,8 +31,7 @@ const KNOWN_SHARED_DIRECTORIES = [ const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); -// Codex creates these runtime locks itself when it starts in a shadow home before the shared -// home has one. They guard shared state, so a local copy is replaced with the shared link. +// Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set(["mcp-oauth-locks", ".sqlite-maintenance.lock"]); function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 1f4a238fa34c9..f9728f9a24973 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -235,8 +235,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu ); yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe( Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)), - // Sign-out closes the scope of an in-flight probe, which fails that refresh - // with an interrupt. Keep listening so the sign-out's own idle still refreshes. + // Sign-out interrupts in-flight probes; keep listening for its subsequent idle refresh. Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))), Effect.forkScoped, ); @@ -301,10 +300,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu enabled ? resolveRuntime.pipe( Effect.matchEffect({ - // Signed out or not set up, there is nothing to scan with and the - // machine snapshot's empty inventory says as much. Otherwise the - // runtime failed for a signed-in account (a token refresh or - // reconnect) or before the first check, and the scan failed. + // Signed-out snapshots have an empty inventory; a signed-in runtime failure must fail the scan. onFailure: (cause) => snapshot.getSnapshot.pipe( Effect.filterOrFail( @@ -327,8 +323,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu }), Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - // Fail rather than return the machine snapshot: the registry - // publishes any returned snapshot as this workspace's inventory. + // Fail instead of returning machine inventory, which the registry would publish as workspace inventory. Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts index 7980c8d0fad07..a08eac31746ff 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.test.ts @@ -162,6 +162,36 @@ describe("readAntigravityUsage", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("prices ACP effort variants at the base model rate", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const dir = yield* tempDir; + yield* seedDatabase(path.join(dir, "effort.db"), (db) => + Effect.gen(function* () { + yield* createTables(db); + for (const [idx, name] of ["gemini-4-argon-high", "gemini-4-argon"].entries()) { + yield* db.run("INSERT INTO gen_metadata VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(1, protoText(19, name))), + ]); + yield* db.run("INSERT INTO steps VALUES (?, ?)", [ + idx, + new Uint8Array(protoBytes(9, protoNumber(2, 10 + idx))), + ]); + } + }), + ); + const result = yield* readAntigravityUsage(dir, 0); + assert.deepStrictEqual( + records(result).map(({ model, rateModel }) => [model, rateModel]), + [ + ["gemini-4-argon-high", "gemini-4-argon"], + ["gemini-4-argon", undefined], + ], + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("merges Antigravity aliases that bridge previously separate step records", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 26db5d8d46f30..7d73f1221cb4b 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -101,6 +101,12 @@ const MODEL_IDS: Record = { 1047: "gemini-3-flash-preview", }; +// ACP slugs append the thinking level, which shares the base model's price. +function withRateModel(record: UsageRecord): UsageRecord { + const rateModel = record.model.replace(/-(?:low|medium|high)$/, ""); + return rateModel === record.model ? record : { ...record, rateModel }; +} + function modelName(name: string, id: number): string { if (name) { const normalized = name @@ -170,11 +176,7 @@ class AntigravityDatabaseDecodeError extends Schema.TaggedError(query, [after, pageRows]) .pipe(Effect.withTracerEnabled(false)); for (const row of rows) { - // Skipped here rather than in SQL, so a statement never scans past - // more than one page of steps that have no metadata. + // Filter outside SQL to bound scans to one page, including rows without metadata. if (step && row.data === null) continue; const idx = row.idx; if (typeof idx !== "number") { @@ -327,12 +326,7 @@ interface CachedDatabase { readonly candidates: readonly UsageCandidate[]; } -/** - * Parsed databases keyed by canonical path. An entry is reused only while both - * the database and its `-wal` sidecar keep the same size, mtime and ctime: new - * rows land in the WAL without touching the main file until a checkpoint, and - * ctime moves on any content write even when mtime is restored. - */ +/** Fingerprint canonical DB/WAL paths by size, mtime and ctime; WAL holds new rows, and ctime catches restored mtimes. */ export const makeAntigravityUsageCache = () => new Map(); export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* ( @@ -482,7 +476,7 @@ export const readAntigravityUsage = Effect.fn("readAntigravityUsage")(function* } for (const [index, group] of groups.entries()) { if (group.parent === index && group.record.timestampMs >= sinceMs) { - files[group.fileIndex]!.records.push(group.record); + files[group.fileIndex]!.records.push(withRateModel(group.record)); } } return { files, errors }; diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts index 1db2197a53e55..a9ebb05a2e1e7 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.test.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts @@ -49,8 +49,7 @@ describe("parseClaudeLine", () => { }); it("gives every content block of one message the same dedupe key", () => { - // T3 Code writes one record per content block, each repeating the parent - // message's full usage. Summing them would overcount ~2.4x on real data. + // Content-block records repeat full parent usage; summing them overcounts. const text = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "text" })); const toolUse = parseClaudeLine(claudeLine({ messageId: "msg_2", contentType: "tool_use" })); diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts index 11f16631bca8f..d9ee4f4a04ff5 100644 --- a/apps/server/src/provider/Drivers/codexUsage.test.ts +++ b/apps/server/src/provider/Drivers/codexUsage.test.ts @@ -444,7 +444,7 @@ describe("parseCodexLine", () => { // A forked/subagent rollout opens with the parent's history copied in and // every line re-stamped to the fork instant, then the ancestors' session - // metas. Counting those again multiplied usage ~1.85x on real data (#5758). + // metas. Counting those again multiplied usage ~1.85x on real data. describe("forked rollouts", () => { const meta = (overrides: { id: string; diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index d3fd0680e31c8..38615eb2cb0cb 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -98,12 +98,7 @@ export function initialCodexScanState(): CodexScanState { }; } -/** - * A forked or subagent rollout opens with the parent's full history copied in, - * every line re-stamped to the fork instant. Those copies are written in one - * synchronous burst (observed gaps 0-40ms), while the child's first genuine - * usage event only lands after a real model turn (observed 5s+). - */ +/** Forks copy and restamp parent history in a burst (0-40ms gaps); genuine child usage follows a model turn (5s+ observed). */ const FORK_COPY_MAX_GAP_MS = 1000; function isForkedSessionMeta(payload: Record): boolean { @@ -141,9 +136,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | const payloadType = payloadRecord["type"]; if (record["type"] === "session_meta") { - // Only the first meta describes this file's own session. A forked rollout - // repeats the ancestors' metas right after it; letting those through would - // reassign every subsequent record to an ancestor session. + // Only the first meta identifies this session; copied ancestor metas would misattribute later records. if (state.sawSessionMeta) return null; state.sawSessionMeta = true; const id = payloadRecord["id"] ?? payloadRecord["session_id"]; @@ -302,10 +295,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): UsageRecord | }; } -/** - * Codex omits the field when no tier was requested, which bills as standard, as - * do `default` and `standard`. `fast` is accepted as an alias of `priority`. - */ +/** Missing, default and standard tiers bill as standard; fast aliases priority. */ function codexSpeed(serviceTier: unknown): UsageSpeed { if (serviceTier === "priority" || serviceTier === "fast") return "fast"; if (serviceTier === "ultrafast") return "ultrafast"; @@ -332,8 +322,7 @@ export const codexUsageFormat: TranscriptUsageFormat = { info: { last_token_usage: true, total_token_usage: true }, }, }, - // `turn_context`, `thread_settings_applied` and `session_meta` lines hold no - // usage, but they carry the model, tier and session the reducer needs. + // Metadata lines carry model, tier and session even without usage. usageMarkers: ['"token_count"', '"turn_context"', '"thread_settings_applied"', '"session_meta"'], parseLine: (line, state) => orEmpty(parseCodexLine(line, state)), parseProjected: (projected, state) => orEmpty(parseCodexRecord(projected, state)), diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 6af7290b5f62d..93061b35913e3 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -1,8 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * Native and canonical views share batching, rotation, and retention state so - * they cannot race while appending to the same thread-scoped file. - */ +/** Native and canonical views share write state to avoid races on the same thread file. */ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -203,8 +200,7 @@ export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unk const nested = Reflect.get(event, "event"); const envelope = typeof nested === "object" && nested !== null ? nested : event; - // Decoded frames carry the same information as raw frames without another - // copy of every token delta. + // Decoded frames preserve raw-frame information without duplicating every token delta. if (Reflect.get(envelope, "stage") === "raw") return false; const decodedPayload = Reflect.get(envelope, "payload"); const nativeEvent = diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 373d9773280a0..4ecc0ad28971b 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -304,7 +304,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { ); // An expired access token prints the logged-out banner, then the CLI refreshes it in the - // same run, so the next `grok models` reports the login (#14285). + // same run, so the next `grok models` reports the login. it.effect("reports authenticated when `grok models` logs in after refreshing a token", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( @@ -352,6 +352,27 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect("warns instead of reporting ready when no models are discovered", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: "You are logged in with grok.com.\n", + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("did not list any models"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + }), + ); + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 27cd700e1a20b..10ea764a734ab 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -26,8 +26,7 @@ const MODEL_MANIFEST_URL = const MANIFEST_TTL_MS = 60 * 60 * 1000; -/** Minimum gap between fetch attempts after a failure, so an offline server - * does not pay a network timeout on every provider check. */ +/** Back off failed fetches so offline health checks do not each wait on the network. */ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; @@ -63,11 +62,7 @@ const ManifestProviderCatalog = Schema.Struct({ const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), - /** - * A release bundles its manifest, and a disk cache of an older edit must not - * outrank it. Optional so older remote files still decode; they count as - * older than any dated bundle. - */ + /** Older disk caches must not override bundled manifests; absent dates rank below any dated bundle. */ updatedAt: Schema.optional(Schema.String), compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), @@ -169,9 +164,7 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; readonly refresh: Effect.Effect; readonly forceRefresh: Effect.Effect; - /** Drivers call this from - * provider checks: the fetch is process-shared state, so it must survive - * the teardown of whichever instance happened to trigger it. */ + /** Process-shared fetches must survive the triggering instance's teardown. */ readonly refreshInBackground: Effect.Effect; } >()("t3/provider/ModelManifest") {} @@ -199,9 +192,7 @@ export const make = Effect.gen(function* () { let lastAttemptMs: number | null = null; const refreshSemaphore = yield* Semaphore.make(1); - // `Effect.cached` makes concurrent first readers await the same disk load - // rather than racing a "loaded" flag. Only `refreshed` takes the fetch - // semaphore; `current` must never wait behind an in-flight network refresh. + // Only refresh takes the fetch semaphore; current must not wait on network I/O. const ensureDiskCacheLoaded = yield* Effect.cached( Effect.gen(function* () { const fromDisk = yield* fileSystem.readFileString(cachePath).pipe( @@ -209,8 +200,6 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); if (fromDisk === null) return; - // Comparing edit dates, not fetch time, keeps this independent of when - // the cache was written relative to the release. if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { return; } @@ -222,9 +211,7 @@ export const make = Effect.gen(function* () { const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; - // A timestamp in the future means the wall clock moved backwards (the - // disk cache crosses restarts, so monotonic time cannot cover it). Treat - // it as expired: the refetch rewrites both timestamps and self-heals. + // Expire future timestamps after clock rollback; disk caches cross restarts, ruling out monotonic time. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; @@ -243,8 +230,7 @@ export const make = Effect.gen(function* () { Effect.timeout(FETCH_TIMEOUT_MS), Effect.catchCause(() => Effect.succeed(null)), ); - // A CDN can still serve an earlier edit after a release. Apply the same - // freshness rule as the disk cache so it cannot undo bundled version gates. + // Reject stale CDN edits just like stale disk caches to preserve bundled version gates. if (fetched === null || manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) { return manifest; } @@ -281,10 +267,11 @@ export const layerModelCatalog = Layer.effect( Effect.gen(function* () { const manifest = yield* ModelManifest; return ModelCatalog.ModelCatalog.of({ - current: (driverKind) => - manifest.current.pipe( + current: Effect.fn("ModelCatalog.current")(function (driverKind) { + return manifest.current.pipe( Effect.map((data) => resolveProviderCatalog(data, driverKind) ?? undefined), - ), + ); + }), bundled: (driverKind) => resolveProviderCatalog(BUNDLED_MODEL_MANIFEST, driverKind) ?? undefined, refreshInBackground: manifest.refreshInBackground, diff --git a/apps/server/src/provider/ProviderAuthService.test.ts b/apps/server/src/provider/ProviderAuthService.test.ts index a5f8ff25aad56..5023dc5bf47ef 100644 --- a/apps/server/src/provider/ProviderAuthService.test.ts +++ b/apps/server/src/provider/ProviderAuthService.test.ts @@ -281,13 +281,11 @@ const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* actions.push(`stop:${providerSessionId}`); yield* input.beforeStop ?? Effect.void; if (input.stopError) { - return yield* Effect.fail( - new ProviderSessionManager.ProviderSessionReleaseError({ - providerSessionId, - reason, - cause: new Error("private process diagnostics"), - }), - ); + return yield* new ProviderSessionManager.ProviderSessionReleaseError({ + providerSessionId, + reason, + cause: new Error("private process diagnostics"), + }); } released.push(providerSessionId); for (const [threadId, list] of sessions) { diff --git a/apps/server/src/provider/ProviderAuthService.ts b/apps/server/src/provider/ProviderAuthService.ts index 5a730c5baf8d4..ed841fde70a66 100644 --- a/apps/server/src/provider/ProviderAuthService.ts +++ b/apps/server/src/provider/ProviderAuthService.ts @@ -12,7 +12,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; -import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; @@ -88,9 +87,7 @@ export const makeProviderAuthService = Effect.gen(function* () { return instance.auth; }); - // Native sessions may still belong to the previous provider after the - // selected model changes. Read session bindings, not the selected model, - // when invalidating credentials for sign-in or sign-out. + // Invalidate credentials using session bindings; model selection may already point to another provider. const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* ( instanceId: ProviderInstanceId, binding: ProviderAuthController["credentialBinding"], @@ -208,8 +205,8 @@ export const makeProviderAuthService = Effect.gen(function* () { }); return yield* auth.reconnectProfile(input.methodId); }), - importProfile: (input) => - credentialChanges.withPermit( + importProfile: Effect.fn("ProviderAuthService.importProfile")(function (input) { + return credentialChanges.withPermit( Effect.gen(function* () { const auth = yield* getController(input.instanceId, "import"); yield* checkSharedBinding(input.instanceId, "start", auth); @@ -224,7 +221,8 @@ export const makeProviderAuthService = Effect.gen(function* () { stopSessions(input.instanceId, auth.credentialBinding), ); }), - ), + ); + }), start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) { return yield* credentialChanges.withPermit( Effect.gen(function* () { diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 5c164f75d8520..8d73d697705fc 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -2,6 +2,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -9,6 +10,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as DocumentText from "./DocumentText.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; export const layer = Layer.effect( @@ -19,6 +21,7 @@ export const layer = Layer.effect( const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const secrets = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; return ProviderHost.ProviderHost.of({ paths: { cwd: config.cwd, @@ -36,8 +39,15 @@ export const layer = Layer.effect( shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), - credentials: (namespace, bindingId) => - ProviderCredentialStore.make(namespace, bindingId).pipe( + // DocumentText owns the path-resolution span. + resolveExtractedTextPath: Effect.fnUntraced( + function* (path) { + return yield* DocumentText.resolveExtractedTextPath(path); + }, + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + credentials: Effect.fn("ProviderHost.credentials")(function (namespace, bindingId) { + return ProviderCredentialStore.make(namespace, bindingId).pipe( Effect.map((store) => ({ binding: store.binding, get: store.get.pipe( @@ -59,7 +69,8 @@ export const layer = Layer.effect( })), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(Crypto.Crypto, crypto), - ), + ); + }), }); }), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 48711552b43c0..0315cb8dfd83d 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -285,8 +285,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { [claudeId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, - // Packaged desktop builds start the server in $HOME, where some - // `claude` wrappers refuse to run. + // Packaged desktop starts in $HOME, where some claude wrappers refuse to run. environment: [{ name: "T3_CLAUDE_REQUIRED_CWD", value: stateDir, sensitive: false }], config: makeClaudeConfig({ enabled: true, diff --git a/apps/server/src/provider/ProviderInstanceRegistry.ts b/apps/server/src/provider/ProviderInstanceRegistry.ts index 1659e8f8fa144..3bbbe2900b9f2 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.ts @@ -29,14 +29,7 @@ export class ProviderInstanceRegistry extends Context.Service< ) => Effect.Effect; readonly listInstances: Effect.Effect>; readonly listUnavailable: Effect.Effect>; - /** - * NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the stream starts - * running, forking a consumer via `Stream.runForEach(...).pipe(Effect.forkScoped)` races - * the next publish — the forked fiber may not have subscribed yet when the publish lands. - * Hot-reload consumers that must not miss a publish should use `subscribeChanges` below - * instead, which acquires the subscription synchronously in the caller's fiber before the - * consumer loop is forked. - */ + /** streamChanges subscribes on consumption; use subscribeChanges before forking to avoid missing the next publish. */ readonly streamChanges: Stream.Stream; readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } @@ -57,11 +50,7 @@ interface RegistryState { const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean => Equal.equals(a, b); -/** - * An explicit false on either the envelope or the raw config blob wins (most - * restrictive) — old settings files can carry both flags with conflicting - * values, and a user's disable must never be silently undone. - */ +/** Conflicting saved flags must preserve any explicit disable. */ const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => { const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config); if (entry.enabled === false || rawConfigEnabled === false) { @@ -177,9 +166,7 @@ const makeReconcile = (input: { nextRaw.map(([raw]) => ProviderInstanceId.make(raw)), ); - // 1. Close scopes for instances that disappeared or whose config - // changed. Do this BEFORE creating replacements so ids map 1-to-1 - // to live scopes at all times. + // Close changed/removed scopes before replacements to preserve one live scope per id. const removedIds: Array = []; const replacedIds = new Set(); for (const [instanceId, live] of previousEntries) { diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 1a722be8e3ec0..4ea8042d02ef1 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -45,9 +45,7 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind }; } - // A project that turns an instance on needs it alive and probed even when the machine itself - // has it off, or a thread in that project would route to an instance nobody ever checked in - // on. + // Probe project-enabled instances even if disabled machine-wide; project threads can still route to them. for (const overrides of Object.values(settings.projectSettingsOverrides)) { for (const [rawInstanceId, projectOverride] of Object.entries( overrides?.providerInstances ?? {}, diff --git a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts index f7675ff33a38f..51322826230d8 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryMutator.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryMutator.ts @@ -1,9 +1,4 @@ -/** - * Closes the per-instance `Scope` of every removed or replaced entry (tearing - * down adapter processes, refresh fibres, temp files) BEFORE creating the - * replacement — `reconcile` guarantees "at most one live instance per id" at - * all times. - */ +/** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */ import type { ProviderInstanceConfigMap } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 266de2339137f..3245ee61294e4 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -16,11 +16,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | CursorKeychain.CursorKeychain | IdAllocator.IdAllocatorV2; -/** - * `providerContinuationRequestsLayer` must be the same layer reference the - * orchestration runtime provides to its continuation worker so Effect layer - * memoization yields one shared queue. - */ +/** Reuse the continuation worker's exact layer reference so memoization shares one queue. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, CodexAdapterV2.layerAppServerClientFactory, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 4aeaddff8f4d5..1c47a2f31c3fe 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -660,8 +660,6 @@ it.layer( message: "Checking ACP authentication, models, and commands in the background...", models: [{ slug: "default", name: "Default", isCustom: false, capabilities: null }], } satisfies ServerProvider; - // A saved sign-in confirmation marks the readiness snapshot authenticated - // before discovery has run. const confirmedCheckingProvider = { ...checkingProvider, auth: { status: "authenticated" }, @@ -695,6 +693,44 @@ it.layer( ); }); + it("drops Grok models a completed probe no longer advertises", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("grok"), + driver: ProviderDriverKind.make("grok"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-30T00:00:00.000Z", + version: "1.0.44", + models: [ + { slug: "grok-4.7", name: "Grok 4.7", isCustom: false, capabilities: null }, + { slug: "codexhub-removed", name: "Removed", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-09-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const acpFailedProvider = { + ...refreshedProvider, + status: "warning", + message: "Grok CLI is installed but ACP initialize failed.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, acpFailedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1248,6 +1284,98 @@ it.layer( ); }); + it.effect( + "a second request for a cwd being scanned gets its result, even if the first leaves", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + const scans = yield* Ref.make(0); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: () => + Ref.update(scans, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as(provider), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-shared-workspace-scan-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const refresh = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const first = yield* Effect.forkChild(refresh); + yield* Deferred.await(scanStarted); + const second = yield* Effect.forkChild(refresh, { startImmediately: true }); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(releaseScan, undefined); + assert.deepStrictEqual( + (yield* Fiber.join(second))[0]?.workspaceSnapshots?.map((snapshot) => snapshot.cwd), + ["/workspace"], + ); + assert.strictEqual(yield* Ref.get(scans), 1); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -1265,7 +1393,6 @@ it.layer( slashCommands: [COMPACT_SLASH_COMMAND], skills: [], } as const satisfies ServerProvider; - // Codex's account check failed; its skill scan did not. const unhealthyProvider = { ...healthyProvider, status: "error", @@ -1336,8 +1463,6 @@ it.layer( yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.slashCommands, [COMPACT_SLASH_COMMAND]); - // An expired scan runs while the account check fails: the skills are - // current, but commands that ride on the health check are unknown. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...unhealthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -1349,14 +1474,10 @@ it.layer( assert.strictEqual(provider?.status, "error"); assert.strictEqual(provider?.message, unhealthyProvider.message); - // A pending entry is rescanned on the next request; an empty scan - // removes the skills. yield* Ref.set(scanResult, { ...unhealthyProvider, skills: [] }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.deepStrictEqual((yield* workspaceOf)?.skills, []); - // A driver that discovers its own commands reports them even while - // health is in error. const discoveredCommand = { name: "review", description: "Review changes" }; yield* Ref.set(scanResult, { ...unhealthyProvider, @@ -1368,7 +1489,6 @@ it.layer( assert.deepStrictEqual(discovered?.slashCommands, [discoveredCommand]); assert.strictEqual(discovered?.slashCommandsPending, undefined); - // Once health recovers, the next scan restores the full entry. yield* TestClock.adjust(PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS); yield* Ref.set(scanResult, { ...healthyProvider, skills: laterSkills }); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); @@ -2150,8 +2270,6 @@ it.layer( describe("checkClaudeProviderStatus", () => { it.effect("reports a logged-out CLI as unauthenticated", () => Effect.gen(function* () { - // The capability probe resolves for a logged-out CLI, so `tokenSource: - // "none"` is the only thing separating it from an authenticated one. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2250,8 +2368,6 @@ it.layer( it.effect("keeps an API key install authenticated when it reports no token source", () => Effect.gen(function* () { - // `ANTHROPIC_API_KEY` never populates `tokenSource`, so reading that - // field alone would log the install out. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities({ @@ -2275,10 +2391,6 @@ it.layer( it.effect("keeps a CLI that says nothing about its account authenticated", () => Effect.gen(function* () { - // Profile-authenticated installs report no token source at all, and a - // CLI too old to send an account payload reports nothing whatsoever. - // Only `tokenSource: "none"` disproves authentication; saying nothing - // is not the same as saying no. const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, claudeCapabilities(), diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97da44aa93ed6..78b11306c5b6d 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -55,11 +56,7 @@ export class ProviderRegistry extends Context.Service< provider?: ProviderDriverKind, ) => Effect.Effect>; - /** - * When the instance id is unknown the call resolves with the currently - * cached list (no error) — matching the legacy `refresh` shim behaviour so - * transport layers don't have to special-case unknowns. - */ + /** Unknown instance ids return the cached list without error. */ readonly refreshInstance: ( instanceId: ProviderInstanceId, ) => Effect.Effect>; @@ -154,9 +151,7 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver === ProviderDriverKind.make("pi")) { - // Pi reports known auth only after RPC inventory discovery completes. - // An empty inventory is warning/unauthenticated; failed or interactive - // discovery stays ready/unknown and must keep the last known models. + // Pi auth becomes known only after inventory discovery: empty means unauthenticated; failed/interactive discovery preserves unknown auth and cached models. return !( provider.installed && (provider.status === "ready" || provider.status === "warning") && @@ -165,13 +160,7 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => } if (provider.driver === ProviderDriverKind.make("acpRegistry")) { - // ACP Registry discovery probes return the agent's complete inventory, so - // a completed probe replaces the model list — otherwise agents that rename - // or collapse models leave stale entries pinned forever through the - // snapshot cache. Only a completed probe reports `nativeSessions`; - // readiness-only and failed probe snapshots only know the "default" - // placeholder and stay partial. Auth is no signal here: agents without - // sign-in stay "unknown" after every probe. + // Only completed ACP discovery provides nativeSessions and inventory; placeholder probes cannot establish auth for agents without sign-in. return !( provider.installed && provider.status === "ready" && @@ -179,6 +168,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } + if (provider.driver === ProviderDriverKind.make("grok")) { + // Grok reports ready only after it discovers a model list; the pending, + // ACP-failed, empty-inventory, and logged-out probes are warning/error. + return !(provider.installed && provider.status === "ready"); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { @@ -239,11 +234,7 @@ const mergeProviderModels = ( : mergedModels; }; -/** - * Antigravity's health check only initializes the agent, so after a server - * restart it reports the account as unchecked. The saved Google login still - * works, and the previous snapshot proves it. - */ +/** Antigravity health checks only initialize; preserve previously verified Google auth across restarts. */ const carrySavedAntigravityAccount = ( previousProvider: ServerProvider, nextProvider: ServerProvider, @@ -326,9 +317,7 @@ const snapshotInstanceKey = (provider: ServerProvider): ProviderInstanceId => { return provider.instanceId; }; -// Each call re-captures the instance's `snapshot` closures, so after -// `ProviderInstanceRegistry` rebuilds an instance (e.g. because its settings -// changed), a fresh source rides the new PubSub instead of a closed one. +// Rebuilt instances have new PubSubs; capture their fresh closures instead of a closed source. const buildSnapshotSource = (instance: ProviderInstance): ProviderSnapshotSource => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, @@ -423,7 +412,7 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap>> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -540,8 +529,7 @@ export const layer = Layer.effect( }, ) { const providers = yield* upsertProviders([provider], options); - // Reclassify the current read model after fetching. Never republish the - // probe captured before the fetch: a newer health result may have landed. + // Reclassify the current model after fetch; republishing the old probe could overwrite newer health results. if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { yield* manifestService.refresh.pipe( Effect.andThen(upsertProviders([], { persist: false })), @@ -625,8 +613,7 @@ export const layer = Layer.effect( }, ); if (claimed.owner) { - // Detached so an interrupted first caller cannot cancel the pass that - // other callers are already awaiting. + // Detach shared passes so the first caller's interruption cannot cancel other callers' work. yield* getLiveSources.pipe( Effect.flatMap((sources) => Effect.forEach(sources, (source) => refreshOneSource(source), { @@ -743,9 +730,7 @@ export const layer = Layer.effect( provider: ProviderDriverKind, options?: { readonly fresh?: boolean }, ) { - // Read the instance registry, not `liveSubsRef`: the latter trails - // reconciliation, and an update must never run a retired instance's - // command against a freshly configured executable. + // liveSubsRef trails reconciliation and could run a retired instance's command; read the registry. const instance = yield* instanceRegistry.getInstance(instanceId); if (!instance || instance.driverKind !== provider) { return makeManualProviderMaintenanceCapabilities(provider); @@ -804,10 +789,7 @@ export const layer = Layer.effect( } } - // Fork long-lived subscriptions to each new/rebuilt instance's - // change stream before reading its current snapshot. If the - // driver's own initial probe finishes during this sync, either - // the current read or the active subscriber observes the result. + // Subscribe before reading so a probe completing mid-sync is observed by at least one path. for (const [, instance] of newlyAdded) { const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => @@ -943,29 +925,19 @@ export const layer = Layer.effect( ) { return providers; } - // Drivers spread their machine snapshot, whose `checkedAt` is the last - // health check. The TTL needs the time this scan started reading files. + // Use scan start for TTL, not checkedAt inherited from the machine health check. const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = Symbol(); - const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Map(current).set(input.cwd, token)); - return [true, next] as const; - }); - if (!claimed) return yield* Ref.get(providersRef); + const token = yield* Deferred.make(); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - return yield* refreshMachineSnapshot.pipe( + const runScan = refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), - // A failed scan fails this effect, so a returned snapshot is a real - // inventory even while the provider's health check reports an error. + // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { @@ -981,9 +953,7 @@ export const layer = Layer.effect( ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, { ...scopedSnapshot, checkedAt: scannedAt, - // Unless the driver reports its own command discovery, - // commands come from the health check. A failed check - // leaves them unknown, so keep the last ones and retry. + // Without driver command discovery, retain previous commands after failed health checks and retry. slashCommandsPending: scopedSnapshot.slashCommandsPending ?? scopedSnapshot.status === "error", }) @@ -1003,9 +973,33 @@ export const layer = Layer.effect( if (nextCwds.size) next.set(instance, nextCwds); else next.delete(instance); return next; - }), + }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), ), ); + // The claim and the fork happen together, so the token is always completed. The scan is + // detached so a disconnecting owner cannot cancel the result its waiters depend on. + const claim = yield* Effect.uninterruptible( + Effect.gen(function* () { + const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + const running = current?.get(input.cwd); + if (!input.fresh && running) return [running, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Map(current).set(input.cwd, token)); + return [undefined, next] as const; + }); + if (inFlight) return { inFlight } as const; + return { + // Starting now attaches the scan's `ensuring` before anything can close the scope. + scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), + } as const; + }), + ); + // Wait for the running scan: on-demand clients get its result only in this reply. + if ("inFlight" in claim) { + return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); + } + return yield* Fiber.join(claim.scan); }); return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 3385886629357..c71d414ccdfab 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -86,9 +86,7 @@ describe("terminatePosixOwnedProcessTree", () => { const scratchRoot = NodePath.join(process.cwd(), "tmp"); NodeFS.mkdirSync(scratchRoot, { recursive: true }); const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-")); - // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the - // test injects populated/remove while still exercising the real selection - // and path-safety logic against a real parent directory tree. + // Unlike cgroupfs, temp directories cannot rmdir with control files; inject populated/remove. const populatedByPath = new Map(); const writeSibling = (name: string, populated: "0" | "1" | null) => { const path = NodePath.join(parent, name); @@ -162,13 +160,12 @@ describe("terminatePosixOwnedProcessTree", () => { expect(AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe( bareGrok, ); - // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone - // (bareGrok lives only in cwd / PATH=scratch). + // Undefined PATH searches /usr/bin:/bin; the bareGrok fixture lives only in cwd. expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), ).toBeUndefined(); expect( - AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("sh", scratch, { PATH: undefined }), ).toBeDefined(); expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { @@ -290,8 +287,7 @@ describe("terminatePosixOwnedProcessTree", () => { }, ); expect(missingTargetResult.status, missingTargetResult.stderr).toBe(125); - // CI's /bin/sh is dash, which runs the EXIT trap after a failed exec. Bash does not, - // so run the same wrapper under bash too. + // dash runs EXIT traps after failed exec; bash does not, so exercise both. const missingTargetBash = NodeChildProcess.spawnSync("bash", missingTarget.args, { encoding: "utf8", }); @@ -422,8 +418,7 @@ describe("terminatePosixOwnedProcessTree", () => { it.live("rotates more than 64 live parents without scanning retained tombstones", () => Effect.gen(function* () { - // The server fixture's process group is the runner's real pid, so a synthetic - // parent sharing it would be skipped as the server's own group. + // Use a different synthetic parent group; the fixture shares the runner's real group, which cleanup skips. const parentPidBase = process.pid < 500_000 ? 500_000 : 1_000; const parents = Array.from({ length: 130 }, (_, index) => identity(parentPidBase + index, 100, parentPidBase + index, parentPidBase + index), @@ -647,7 +642,6 @@ describe("terminatePosixOwnedProcessTree", () => { grace: 0, rootPid: 100, }); - // Zombies remain visible but must not fail residual teardown. expect(fixture.processes.get(100)?.state).toBe("Z"); expect(fixture.processes.get(110)?.state).toBe("Z"); }), diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index b36c4afe91596..bd48ee767ee46 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -8,6 +8,7 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -196,6 +197,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: " Explain this image. ", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -216,6 +218,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [textAttachment], attachmentsDir: selectedEnvironment.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -246,6 +249,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Inspect the pasted text only as needed.", attachments: [pastedText], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); @@ -272,6 +276,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input, attachments: [attachment, pdfAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fixture.fs, @@ -287,6 +292,53 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect.each([undefined, {}, { image: false, audio: false, embeddedContext: false }])( + "keeps attachments path-only when native prompt capabilities are absent: %s", + (promptCapabilities) => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const audio = { + ...textAttachment, + id: "unsupported-audio", + name: "audio.wav", + mimeType: "audio/wav", + }; + const gifAttachment = { + ...imageAttachment, + id: "unsupported-gif", + name: "animation.gif", + mimeType: "image/gif", + }; + const gif = yield* fixture.write(gifAttachment, "GIF89a"); + const image = yield* fixture.write(imageAttachment, "pixels"); + const pdf = yield* fixture.write(pdfAttachment, "%PDF-1.7"); + const text = yield* fixture.write(textAttachment, "hello"); + const sound = yield* fixture.write(audio, "RIFF"); + const input = `Read ${image.filePath}, ${gif.filePath}, ${pdf.filePath}, ${text.filePath}, and ${sound.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [imageAttachment, gifAttachment, pdfAttachment, textAttachment, audio], + attachmentsDir: fixture.attachmentsDir, + promptCapabilities, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => + Effect.die("Unnegotiated attachments must not be read").pipe(Stream.fromEffect), + }), + ); + expect(prompt).toEqual([{ type: "text", text: input }]); + expect( + yield* buildAntigravityPrompt({ + input: "Continue", + attachments: [], + attachmentsDir: fixture.attachmentsDir, + promptCapabilities, + }), + ).toEqual([{ type: "text", text: "Continue" }]); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); @@ -302,6 +354,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Transcribe this.", attachments: [audioAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }); expect(prompt).toEqual([ @@ -311,25 +364,27 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([ - { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }, - ] satisfies ReadonlyArray)( - "rejects $name instead of silently dropping it from a valid prompt", - (attachment) => + it.effect.each([undefined, {}, { image: false }, { image: true }])( + "keeps unsupported image formats path-only with capabilities %s", + (promptCapabilities) => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); - yield* fixture.write(imageAttachment, new Uint8Array([1, 2, 3])); - const error = yield* buildAntigravityPrompt({ - input: "Analyze every attachment.", - attachments: [imageAttachment, attachment], + const attachment = { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" }; + const upload = yield* fixture.write(attachment, "GIF89a"); + const input = `Analyze the image at ${upload.filePath}`; + const prompt = yield* buildAntigravityPrompt({ + input, + attachments: [attachment], attachmentsDir: fixture.attachmentsDir, - }).pipe(Effect.flip); + promptCapabilities, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fixture.fs, + stream: () => Effect.die("Unsupported images must not be read").pipe(Stream.fromEffect), + }), + ); - expect(error).toMatchObject({ - _tag: "AcpRequestError", - code: -32602, - errorMessage: expect.stringContaining(`does not support '${attachment.name}'`), - }); + expect(prompt).toEqual([{ type: "text", text: input }]); }), ); @@ -342,6 +397,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Read this attachment.", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", @@ -367,6 +423,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: `Read ${first.filePath} and ${second.filePath}`, attachments: [pdfAttachment, secondAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }; const prompt = yield* buildAntigravityPrompt(input); expect(prompt).toEqual([ @@ -408,6 +465,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Inspect both attachments.", attachments: [pdfAttachment, imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", @@ -426,6 +484,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [pdfAttachment, textAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe( Effect.provideService(FileSystem.FileSystem, { ...fixture.fs, @@ -459,6 +518,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: undefined, attachments: [textAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ @@ -476,6 +536,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { input: "Read this image.", attachments: [imageAttachment], attachmentsDir: fixture.attachmentsDir, + promptCapabilities: { image: true, audio: true, embeddedContext: true }, }).pipe(Effect.flip); expect(error).toMatchObject({ diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 9ba55af7cf2ca..8ddbf575c8f1e 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -231,6 +231,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi readonly input: ProviderSendTurnInput["input"]; readonly attachments: ProviderSendTurnInput["attachments"]; readonly attachmentsDir: string; + readonly promptCapabilities: EffectAcpSchema.AgentCapabilities["promptCapabilities"]; }): Effect.fn.Return< ReadonlyArray, EffectAcpErrors.AcpError, @@ -248,9 +249,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi attachment.type === "file" && "source" in attachment && attachment.source?._tag === "pasted-text"; - // ProviderService has already put the file path in the text block. Keep a - // folded clipboard paste lazy so the agent can search or sample it rather - // than paying to embed the entire resource in context immediately. + // The text already carries the path; keep folded pastes lazy so agents can search without embedding the whole file. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -261,12 +260,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi TEXT_MIME_TYPES.has(mimeType) || TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase())); const isPathOnly = - attachment.type === "file" && (isPastedText || (!audio && !pdf && !textFile)); - if (attachment.type === "image" && !image) { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`, - ); - } + (attachment.type === "image" && (!image || input.promptCapabilities?.image !== true)) || + (attachment.type === "file" && + (isPastedText || + (audio + ? input.promptCapabilities?.audio !== true + : pdf || textFile + ? input.promptCapabilities?.embeddedContext !== true + : true))); const attachmentPath = resolveAttachmentPath({ attachmentsDir: input.attachmentsDir, attachment, diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index b31b66149c7c4..ebbc234e149b9 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -4,10 +4,7 @@ import type * as Path from "effect/Path"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; -/** - * The agent gates each write behind `session/request_permission`, so only - * path containment is checked here. - */ +/** The agent requests permission for every write; check only path containment here. */ const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean { @@ -15,10 +12,7 @@ function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -/** - * Symlinks resolve before the check, including the final component, so a link - * inside the workspace cannot read or write through to a file outside it. - */ +/** Resolve all symlinks, including the final component, before checking workspace containment. */ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFilePath")( function* (input: { readonly fileSystem: FileSystem.FileSystem; @@ -34,8 +28,7 @@ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFil const real = yield* input.fileSystem.realPath(resolved).pipe( Effect.catch(() => Effect.gen(function* () { - // Only a missing file (a new write) falls back to its parent; a - // dangling or unreadable link must not be followed on write. + // Fall back to the parent only for missing files, never dangling or unreadable links. const entryExists = yield* input.fileSystem.readLink(resolved).pipe( Effect.as(true), Effect.catch(() => input.fileSystem.exists(resolved)), diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 5c1ee06ee5f2e..1ca5f7fc91bb3 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -69,10 +69,7 @@ const decodeSecurityWarning = Schema.decodeUnknownOption( }), ); -/** - * The agent marks "Allow Always" on shell and web tools with a prompt injection - * warning in `_meta`. Surface it as option text so both clients can show it. - */ +/** Expose shell/web Allow Always _meta warnings as option text for both clients. */ function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; @@ -231,6 +228,7 @@ function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: num export function sanitizeAntigravityToolPayload(payload: unknown): unknown { return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0); } +const isJsonRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); export function normalizeAntigravitySessionUpdate( notification: EffectAcpSchema.SessionNotification, @@ -259,9 +257,7 @@ export function normalizeAntigravitySessionUpdate( ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) } : {}), ...(update.content !== undefined ? { content: content ?? [] } : {}), - ...(update._meta !== undefined - ? { _meta: Schema.is(Schema.Record(Schema.String, Schema.Json))(meta) ? meta : null } - : {}), + ...(update._meta !== undefined ? { _meta: isJsonRecord(meta) ? meta : null } : {}), }, }; } @@ -346,10 +342,7 @@ export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { return toolCall.kind === "execute" && toolCall.status === "inProgress"; } -/** - * ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs - * or models. - */ +/** ACP 1.1.1 reports subagent calls as ordinary tools without child IDs or models. */ export function classifyAntigravitySubagentToolCall( toolCall: AcpToolCallState, rawPayload?: unknown, diff --git a/apps/server/src/provider/acp/AntigravitySessionFiles.ts b/apps/server/src/provider/acp/AntigravitySessionFiles.ts index be6174c33ffdb..7a25c8323174d 100644 --- a/apps/server/src/provider/acp/AntigravitySessionFiles.ts +++ b/apps/server/src/provider/acp/AntigravitySessionFiles.ts @@ -42,12 +42,7 @@ export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySession Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")), ); -/** - * Call once when the driver starts, before it launches any process, so a previous server that was - * killed mid-session cannot leave unpacked runtimes behind. The system temp directory belongs to - * other programs and Windows does not lock data files, so sweeping it could gut a live - * extraction. - */ +/** Sweep only private runtimes before launch; Windows does not protect live extracted data files from deletion. */ export const removeAntigravityRuntimeTempDirs = Effect.fn("removeAntigravityRuntimeTempDirs")( function* (tempDirectory: string) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 4e4d32ffc1075..5030b53005e89 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -51,9 +51,7 @@ const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSet const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); -// Python splits BROWSER on the platform path separator before it parses quotes. -// EPIPE must still exit 0 so Python does not fall back to an OS browser after -// cancellation. +// Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = `process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + @@ -172,10 +170,7 @@ export function isAntigravitySignInRequiredError(error: unknown): boolean { export interface AntigravityInstanceDirectories { readonly profile: string; - /** - * It sits beside the profile, not inside it: the agent unpacks members up to 120 characters deep, - * and the profile's longer name would push them past Windows' 260-character path limit. - */ + /** Keep beside the profile: 120-character archive members under its longer name would exceed Windows' 260-character limit. */ readonly runtimeTemp: string; } @@ -207,17 +202,14 @@ function antigravityEnvironment( // Windows treats environment keys as case-insensitive. Remove aliases too. if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value; } - // The agent prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair goes through - // settings.json instead of the environment. + // Put the GCP pair in settings.json because GOOGLE_API_KEY takes precedence in the environment. const credential = auth.authMethod === "gemini-api-key" && auth.apiKey ? { GEMINI_API_KEY: auth.apiKey } : auth.authMethod === "agent-platform" && auth.apiKey ? { GOOGLE_API_KEY: auth.apiKey } : {}; - // It unpacks about 1 GB into the system temp directory per launch and a force - // kill leaves that behind. Point it at a T3-owned directory so the driver can - // reclaim the space. + // Each launch unpacks about 1 GB; use T3-owned temp so the driver can reclaim files after force-kill. const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory; return { ...environment, @@ -238,12 +230,7 @@ function antigravityEnvironment( }; } -/** - * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points - * at the private profile. Link the two skill directories back to the user's - * real `~/.gemini` so global skills load, while MCP servers, hooks, and - * credentials stay isolated. - */ +/** Private GEMINI_HOME isolates credentials/hooks/MCP; link global skills from the real ~/.gemini. */ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { readonly profileDirectory: string; readonly userHome: string; diff --git a/apps/server/src/provider/antigravityUsageLimits.test.ts b/apps/server/src/provider/antigravityUsageLimits.test.ts index d494c4924d0e0..7c0eeabb0a755 100644 --- a/apps/server/src/provider/antigravityUsageLimits.test.ts +++ b/apps/server/src/provider/antigravityUsageLimits.test.ts @@ -146,7 +146,6 @@ describe("Antigravity subscription limits", () => { expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(56); yield* probe; expect(requests.filter((request) => request.url.endsWith("/token"))).toHaveLength(1); - // Both quota endpoints are read on every probe. expect( requests.filter((request) => request.url.endsWith(":retrieveUserQuotaSummary")), ).toHaveLength(4); @@ -157,8 +156,7 @@ describe("Antigravity subscription limits", () => { Effect.gen(function* () { const { probe } = yield* fixture({ summaryByHost: { - // The daily endpoint has answered with only the Claude/GPT group; the - // other endpoint returns the full default summary. + // The daily endpoint can omit Gemini while the other endpoint returns all groups. "daily-cloudcode-pa.googleapis.com": { groups: [ { @@ -181,8 +179,6 @@ describe("Antigravity subscription limits", () => { ]); const byId = new Map(limits.windows.map((window) => [window.id, window])); expect(byId.get("gemini-weekly")?.label).toBe("Gemini · Weekly"); - // When endpoints disagree about a bucket, the most-used reading wins, - // whichever endpoint reported it. expect(byId.get("3p-5h")?.usedPercent).toBeCloseTo(70); expect(byId.get("3p-weekly")?.usedPercent).toBe(100); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), diff --git a/apps/server/src/provider/antigravityUsageLimits.ts b/apps/server/src/provider/antigravityUsageLimits.ts index 55ef3579942fb..75333ae8262e7 100644 --- a/apps/server/src/provider/antigravityUsageLimits.ts +++ b/apps/server/src/provider/antigravityUsageLimits.ts @@ -223,8 +223,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( ), ).pipe(Effect.flatMap(decodeTokenResponse)); accessToken = refreshed.access_token; - // Each machine's sign-in has its own refresh token, so the Google - // account id is what lets clients merge one account across environments. + // Use Google account IDs to merge accounts across environments; refresh tokens differ per machine. account = yield* request( HttpClientRequest.get("https://www.googleapis.com/oauth2/v2/userinfo").pipe( HttpClientRequest.setHeaders({ Authorization: `Bearer ${accessToken}` }), @@ -246,9 +245,7 @@ export const makeAntigravityUsageProbe = Effect.fn("makeAntigravityUsageProbe")( accessToken = cached.accessToken; account = cached.account; } - // Endpoints can report different quota groups (the daily endpoint has - // returned the Claude/GPT group without Gemini), so merge every endpoint's - // windows. The most-used reading of a bucket wins, as in modelWindows. + // Merge endpoints because quota groups differ; keep each bucket's most-used reading. const fetchWindows = Effect.fn("AntigravityUsageProbe.fetchWindows")(function* ( operation: string, parse: (response: unknown) => Effect.Effect, diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index b66dac4c15a6f..32b2347589c8c 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -48,10 +48,7 @@ export type BuiltInUsageReadersEnv = | ProviderUsageReaderEnv | ProviderUsageReaderEnv; -/** - * Aggregation keeps the first copy of a duplicate record, so the order is part - * of the result. - */ +/** Order matters: aggregation keeps the first duplicate record. */ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< AnyProviderDriver > = [ diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index fbc3efa2e5d4c..35973378edada 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -81,9 +81,7 @@ class ClaudeResetCreditError extends Schema.TaggedError( const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); -/** - * An unanswered or unconfirmed claim retries with the same request id. - */ +/** Retry unanswered or unconfirmed claims with the same request ID. */ export const isSettledClaudeResetCreditFailure = (error: unknown) => isClaudeResetCreditError(error) && error.reason !== "requestFailed" && @@ -196,10 +194,7 @@ const CLAIM_OUTCOMES = { ineligible: "noCredit", } as const satisfies Record; -/** - * `requestId` is the idempotency key: a retry with the same id is the same - * claim. - */ +/** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; readonly accountConfigPath: string; @@ -255,8 +250,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (body.result === "cooldown") { return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); } - // Claude could not say whether the claim landed, so, like the CLI, keep the - // request id and let the retry ask about the same claim. + // Unknown outcomes retain the request ID so retries address the same claim. if (body.result === "unavailable") { return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); } diff --git a/apps/server/src/provider/claudeUsageLimits.test.ts b/apps/server/src/provider/claudeUsageLimits.test.ts index 7fc0a63a605da..f20d8b0543636 100644 --- a/apps/server/src/provider/claudeUsageLimits.test.ts +++ b/apps/server/src/provider/claudeUsageLimits.test.ts @@ -16,8 +16,6 @@ describe("claudeUsageResponseToLimits", () => { five_hour: { utilization: 54, resets_at: "2026-07-18T14:39:00Z" }, seven_day: { utilization: 18.4, resets_at: "2026-07-24T08:59:00+00:00" }, seven_day_opus: { utilization: 3, resets_at: null }, - // Newer CLIs add this on top of the typed keys; the pinned SDK - // typings do not know it yet. ...({ model_scoped: [ { display_name: "Fable", utilization: 73, resets_at: "2026-07-24T08:59:00Z" }, diff --git a/apps/server/src/provider/claudeUsageLimits.ts b/apps/server/src/provider/claudeUsageLimits.ts index e41dac250aceb..d0acf43bb4968 100644 --- a/apps/server/src/provider/claudeUsageLimits.ts +++ b/apps/server/src/provider/claudeUsageLimits.ts @@ -26,13 +26,7 @@ const WINDOWS: Readonly< seven_day: { kind: "weekly", label: "Weekly", windowDurationMins: WEEK_MINS }, }; -/** - * The streamed event names the overage-included bucket by type - * (`seven_day_overage_included`), while `get_usage` names it by the model's - * `display_name`. Which model that is changes over time, so the probe records - * the name it saw and the event mapper reuses it; the mid-turn update then - * lands on the row the probe drew instead of opening a second one. - */ +/** Reuse probed display names for streamed bucket types so mid-turn updates target the same row. */ const OVERAGE_INCLUDED_EVENT_TYPE = "seven_day_overage_included"; // The SDK does not yet type the spend budget returned by `get_usage`. @@ -157,11 +151,7 @@ function makeWindow( }; } -/** - * Utilization is a 0–1 fraction on the streamed event. An overage-included - * event before any probe has named the bucket is dropped: guessing a name - * would draw a row the next probe cannot reconcile. - */ +/** Stream utilization is 0-1. Drop unnamed overage buckets until probed to avoid unreconcilable rows. */ export function claudeRateLimitEventToUpdate( info: SDKRateLimitInfo, names: ClaudeScopedLimitNames, @@ -181,9 +171,7 @@ export function claudeRateLimitEventToUpdate( return undefined; } -/** - * Percentages on the `get_usage` response are already 0–100. - */ +/** get_usage percentages are already 0-100. */ export function claudeUsageResponseToLimits(input: { readonly response: Pick; readonly checkedAt: string; @@ -204,8 +192,7 @@ export function claudeUsageResponseToLimits(input: { if (!window || typeof window.utilization !== "number") continue; windows.push(makeWindow(id, window.utilization, isoFromString(window.resets_at))); } - // The CLI filters `model_scoped` to the overage-included allowlist, which - // today holds one model; the first entry is the one the event refers to. + // The CLI's overage allowlist currently has one model; events refer to its first entry. let overageIncluded: string | undefined; for (const entry of readModelScoped(response.rate_limits)) { if (typeof entry.utilization !== "number") continue; diff --git a/apps/server/src/provider/codexModelCatalog.ts b/apps/server/src/provider/codexModelCatalog.ts index 037dad8b0d9bc..a459b1af81de0 100644 --- a/apps/server/src/provider/codexModelCatalog.ts +++ b/apps/server/src/provider/codexModelCatalog.ts @@ -14,10 +14,7 @@ const CodexModelAdapter = Schema.Struct({ }); const decodeCodexModelAdapter = Schema.decodeUnknownOption(CodexModelAdapter); -/** - * Codex lists only the models its own build knows, so a model released after - * the installed CLI never shows up. - */ +/** Codex catalogs omit models released after the installed CLI. */ function codexUpdateRequiredModels( catalog: ModelCatalog.ProviderCatalog | undefined, draft: ServerProviderDraft, diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index 0baae05dd816d..053700026b520 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -56,16 +56,11 @@ function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { return kind === "session" ? "Session" : kind === "weekly" ? "Weekly" : "Monthly"; } -/** - * `primary` / `secondary` are positions, not durations. Codex usually sends - * `windowDurationMins`; when it does not, paid plans expose the 5-hour and - * weekly pair and Free/Go expose one monthly allowance. - */ +/** primary/secondary are positions; without windowDurationMins, paid plans use 5-hour/weekly windows and Free/Go use monthly. */ function codexRateLimitsToWindows( snapshot: CodexRateLimitSnapshot, ): ReadonlyArray { - // Show the main allowance only. Model-specific notifications (such as Spark) - // must not replace its primary/secondary rows. Older CLIs omit the limit id. + // Keep model-specific limits out of main allowance rows; older CLIs omit limit IDs. if (snapshot.limitId && snapshot.limitId !== "codex") return []; const isMonthlyPlan = snapshot.planType === "free" || snapshot.planType === "go"; const positions = [ @@ -158,18 +153,12 @@ export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerE } } -/** - * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a - * field the update omits keeps the value observed earlier in the session, so a - * later notification that only names the limit it reached must not drop the - * windows an earlier one carried. - */ +/** Rate-limit notifications are partial: omitted fields preserve earlier session values. */ export function mergeCodexRateLimits( previous: CodexRateLimitSnapshot | undefined, update: CodexRateLimitSnapshot, ): CodexRateLimitSnapshot | undefined { - // Model-specific snapshots (such as Spark) describe a different allowance - // and must not overwrite the main one, the same rule the usage rows apply. + // Model-specific snapshots must not overwrite the main allowance. if (update.limitId && update.limitId !== "codex") return previous; if (!previous) return update; return { diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts new file mode 100644 index 0000000000000..569493906d385 --- /dev/null +++ b/apps/server/src/provider/documentTextWorker.ts @@ -0,0 +1,648 @@ +// @effect-diagnostics nodeBuiltinImport:off -- isolated CLI worker; no server runtime is loaded here. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { characterEntities } from "character-entities"; +import { SaxesParser } from "saxes"; +import * as Yauzl from "yauzl"; + +const MAX_INPUT = 16 * 1024 * 1024; +const MAX_EXPANDED = 32 * 1024 * 1024; +const MAX_OUTPUT = 1_000_000; +const CELL_START = Symbol("cell-start"); +const CELL_END = Symbol("cell-end"); +type TextPart = string | typeof CELL_START | typeof CELL_END | { fallback: string }; + +const TRUNCATED = "\n\n[Extracted text truncated at document extraction limits.]"; + +class Truncation extends Error {} + +interface XmlNode { + name: string; + attrs: Record; + children: Array; + spaceCount?: number; +} + +function xml(source: string, odfSpaces = false, htmlEntities = false): XmlNode { + const root: XmlNode = { name: "root", attrs: {}, children: [] }; + const stack = [root]; + let nodes = 0; + const parser = new SaxesParser(); + if (htmlEntities) Object.assign(parser.ENTITIES, characterEntities); + parser.on("opentag", (tag) => { + if (++nodes > 200_000 || stack.length > 128) throw new Truncation("XML structure limit"); + const node: XmlNode = { + name: tag.name.split(":").at(-1)!, + attrs: Object.fromEntries( + Object.entries(tag.attributes).flatMap(([key, value]) => [ + [key, String(value)], + [key.split(":").at(-1)!, String(value)], + ]), + ), + children: [], + }; + if (odfSpaces && node.name === "s") node.spaceCount = repeatCount(node.attrs.c); + stack.at(-1)!.children.push(node); + stack.push(node); + }); + parser.on("closetag", () => { + stack.pop(); + }); + const text = (value: string) => { + stack.at(-1)!.children.push(value); + }; + parser.on("text", text); + parser.on("cdata", text); + parser.write(source).close(); + return root; +} + +const children = (node: XmlNode) => + node.children.filter((child): child is XmlNode => typeof child !== "string"); +function* all(node: XmlNode, name: string | readonly string[]): Generator { + for (const child of children(node)) { + if (typeof name === "string" ? child.name === name : name.includes(child.name)) yield child; + else yield* all(child, name); + } +} +function* text(node: XmlNode): Generator { + if (["script", "style", "binary-data", "rPh"].includes(node.name)) return; + if (["tab", "cell"].includes(node.name)) yield "\t"; + else if (["br", "line-break", "cr"].includes(node.name)) yield "\n"; + else if (node.spaceCount !== undefined) { + yield " ".repeat(Math.min(MAX_OUTPUT, node.spaceCount)); + if (node.spaceCount > MAX_OUTPUT) throw new Truncation("Space repeat limit"); + } else + for (const child of node.children) { + if (typeof child === "string") yield child; + else yield* text(child); + } +} +function* paragraphs(node: XmlNode): Generator { + if (["script", "style", "binary-data"].includes(node.name)) return; + for (const child of node.children) { + if (typeof child === "string") yield child; + else if (["br", "line-break", "cr"].includes(child.name)) yield "\n"; + else if (["tbl", "table"].includes(child.name)) yield* table(child); + else if (["p", "h"].includes(child.name) || /^h[1-6]$/.test(child.name)) { + const style = all(child, "pStyle").next().value?.attrs.val ?? ""; + const level = + child.name === "h" + ? Number(child.attrs["outline-level"]) || 1 + : /^h[1-6]$/.test(child.name) + ? Number(child.name[1]) + : Number(style.match(/^Heading([1-6])$/i)?.[1]) || 0; + if (level) yield `${"#".repeat(Math.min(6, level))} `; + yield* text(child); + yield "\n"; + } else { + yield* paragraphs(child); + if (["li", "div", "section"].includes(child.name)) yield "\n"; + } + } +} +function* inCell(parts: Iterable): Generator { + yield CELL_START; + yield* parts; + yield CELL_END; +} +function repeatCount(value: string | undefined): number { + if (value === undefined) return 1; + if (!/^[0-9]+$/.test(value) || Number(value) < 1) throw new Error("Invalid repeat count"); + return Number(value); +} +function odfValue(cell: XmlNode): string { + const type = cell.attrs["office:value-type"] ?? cell.attrs["value-type"]; + const attr = (name: string) => cell.attrs[`office:${name}`] ?? cell.attrs[name] ?? ""; + switch (type) { + case "void": + return ""; + case "string": + return attr("string-value"); + case "boolean": + return attr("boolean-value"); + case "date": + return attr("date-value"); + case "time": + return attr("time-value"); + case "currency": + return attr("value").trim() ? `${attr("value")} ${attr("currency")}`.trim() : ""; + case "percentage": + return attr("value").trim() ? `${attr("value")} [ODF value-type=percentage]` : ""; + default: + return attr("value"); + } +} +function hasCellText(cell: XmlNode): boolean { + if (odfValue(cell).trim()) return true; + // Space markers can have enormous repeat counts; emptiness never requires expanding them. + if ( + ["script", "style", "binary-data", "rPh"].includes(cell.name) || + cell.spaceCount !== undefined + ) + return false; + return cell.children.some((child) => + typeof child === "string" ? !!child.trim() : hasCellText(child), + ); +} +function* table(node: XmlNode): Generator { + let rowCount = 0; + for (const row of all(node, ["tr", "table-row"])) { + const cells = children(row).filter((cell) => + ["tc", "table-cell", "covered-table-cell", "td", "th"].includes(cell.name), + ); + // ODS writers pad sheets with repeated empty rows and trailing cells. + while (cells.length && !hasCellText(cells.at(-1)!)) cells.pop(); + if (!cells.length) continue; + const repeat = repeatCount(row.attrs["number-rows-repeated"]); + for (let index = 0; index < repeat; index++) { + if (++rowCount > 10_000) throw new Truncation("Row limit"); + let column = 0; + for (const cell of cells) { + const count = repeatCount(cell.attrs["number-columns-repeated"]); + for (let i = 0; i < count; i++) { + if (column >= 512) throw new Truncation("Column limit"); + if (column++) yield "\t"; + yield CELL_START; + yield* paragraphs(cell); + yield { fallback: odfValue(cell) }; + yield CELL_END; + } + } + yield "\n"; + } + } +} + +async function archive(path: string): Promise> { + const zip = await new Promise((resolve, reject) => + Yauzl.open( + path, + { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, + (error, result) => (error || !result ? reject(error) : resolve(result)), + ), + ); + const files = new Map(); + let expanded = 0; + let count = 0; + try { + await new Promise((resolve, reject) => { + zip.on("error", reject); + zip.on("end", resolve); + zip.on("entry", (entry: Yauzl.Entry) => { + if (++count > 2048 || zip.entryCount > 2048) { + reject(new Truncation("Archive limit")); + return; + } + if (entry.generalPurposeBitFlag & 1) { + reject(new Error("Encrypted archive")); + return; + } + if (!/\.(xml|rels|xhtml|html|htm|opf|svg)$/i.test(entry.fileName)) { + zip.readEntry(); + return; + } + if ( + entry.uncompressedSize > MAX_EXPANDED || + entry.uncompressedSize > Math.max(1024, entry.compressedSize * 200) + ) { + reject(new Truncation("Archive limit")); + return; + } + expanded += entry.uncompressedSize; + if (expanded > MAX_EXPANDED) { + reject(new Truncation("Expanded byte limit")); + return; + } + zip.openReadStream(entry, (error, stream) => { + if (error || !stream) { + reject(error); + return; + } + const buffers: Buffer[] = []; + let size = 0; + stream.on("error", reject); + stream.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > entry.uncompressedSize || size > MAX_EXPANDED) { + stream.destroy(new Truncation("Entry byte limit")); + return; + } + buffers.push(chunk); + }); + stream.on("end", () => { + const data = Buffer.concat(buffers); + // XML's BOM and initial '<' bytes distinguish both UTF-16 byte orders. + const encoding = + (data[0] === 0xff && data[1] === 0xfe) || (data[0] === 0x3c && data[1] === 0) + ? "utf-16le" + : (data[0] === 0xfe && data[1] === 0xff) || (data[0] === 0 && data[1] === 0x3c) + ? "utf-16be" + : "utf-8"; + files.set(entry.fileName, new TextDecoder(encoding).decode(data)); + zip.readEntry(); + }); + }); + }); + zip.readEntry(); + }); + } finally { + zip.close(); + } + return files; +} + +function* notebook(source: string): Generator { + const book: unknown = JSON.parse(source); + if (!book || typeof book !== "object" || !("cells" in book) || !Array.isArray(book.cells)) + throw new Error("Invalid notebook"); + function* lines(value: unknown): Generator { + if (typeof value === "string") yield value; + else if (Array.isArray(value)) + for (const line of value) { + if (typeof line === "string") yield line; + } + } + for (const [index, cell] of book.cells.entries()) { + if (index >= 10_000) throw new Truncation("Notebook cell limit"); + if (!cell || typeof cell !== "object") continue; + const kind = "cell_type" in cell && cell.cell_type === "code" ? "code" : "markdown"; + yield `## Cell ${index + 1} (${kind})\n`; + if ("source" in cell) yield* lines(cell.source); + if ("outputs" in cell && Array.isArray(cell.outputs)) { + yield "\nOutput:\n"; + for (const output of cell.outputs) { + if (!output || typeof output !== "object") continue; + if ("text" in output) yield* lines(output.text); + else if ( + "data" in output && + output.data && + typeof output.data === "object" && + "text/plain" in output.data + ) + yield* lines(output.data["text/plain"]); + else if ("output_type" in output && output.output_type === "error") { + if ("ename" in output) yield* lines(output.ename); + if ("evalue" in output) { + yield ": "; + yield* lines(output.evalue); + } + yield "\n"; + if ("traceback" in output) + for (const line of lines(output.traceback)) { + yield NodeUtil.stripVTControlCharacters(line); + yield "\n"; + } + } + yield "\n"; + } + } + yield "\n\n"; + } +} + +function* rtf(source: string): Generator { + if (!source.startsWith("{\\rtf")) throw new Error("Invalid RTF document"); + let state = { skip: false, uc: 1, decoder: new TextDecoder("windows-1252") }; + const stack: Array = []; + const symbols = new Map([ + ["endash", "–"], + ["emdash", characterEntities.mdash], + ["enspace", "\u2002"], + ["emspace", "\u2003"], + ["qmspace", "\u2005"], + ["lquote", "‘"], + ["rquote", "’"], + ["ldblquote", "“"], + ["rdblquote", "”"], + ["bullet", "•"], + ["~", "\u00a0"], + ["_", "\u2011"], + ["-", ""], + ]); + let fallback = 0; + for (let i = 0; i < source.length; i++) { + const char = source[i]!; + if (char === "{" || char === "}") fallback = 0; + if (char === "{") { + stack.push({ ...state }); + if (stack.length > 128) throw new Truncation("RTF depth limit"); + } else if (char === "}") state = stack.pop() ?? state; + else if (char === "\\") { + const rest = source.slice(i + 1, i + 80); + const hex = /^'([0-9a-f]{2})/i.exec(rest); + const word = /^([a-z]+)(-?\d+)? ?/i.exec(rest); + if (hex) { + if (fallback) fallback--; + else if (!state.skip) + yield state.decoder.decode(Uint8Array.of(Number.parseInt(hex[1]!, 16))); + i += 3; + } else if (word) { + i += word[0].length; + const control = word[1]!; + const value = Number(word[2]); + if (control === "bin") { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("Invalid RTF binary length"); + i += value; + } + if (fallback) { + fallback--; + continue; + } + if ( + [ + "fonttbl", + "colortbl", + "stylesheet", + "info", + "pict", + "object", + "fldinst", + "header", + "footer", + ].includes(control) + ) + state.skip = true; + if (control === "ansicpg") { + // Per-byte decoding only supports single-byte Windows code pages. + if (value !== 874 && (value < 1250 || value > 1258)) + throw new Error("Unsupported RTF code page"); + state.decoder = new TextDecoder(`windows-${value}`, { fatal: true }); + } else if (control === "uc") { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error("Invalid RTF fallback count"); + state.uc = value; + } else if (control === "u" && !state.skip) { + yield String.fromCharCode(value < 0 ? value + 65536 : value); + fallback = state.uc; + } else if (!state.skip && ["par", "line", "row"].includes(control)) yield "\n"; + else if (!state.skip && ["tab", "cell"].includes(control)) yield "\t"; + else if (!state.skip && symbols.has(control)) yield symbols.get(control)!; + } else { + i++; + if (fallback) fallback--; + else if (rest[0] === "*") state.skip = true; + else if (!state.skip && ["\\", "{", "}"].includes(rest[0]!)) yield rest[0]!; + else if (!state.skip && symbols.has(rest[0]!)) yield symbols.get(rest[0]!)!; + } + } else if (!state.skip && char !== "\n" && char !== "\r") { + if (fallback) fallback--; + else + yield char.charCodeAt(0) < 128 + ? char + : state.decoder.decode(Uint8Array.of(char.charCodeAt(0))); + } + } +} + +async function* document(path: string, extension: string): AsyncGenerator { + if ((await NodeFSP.stat(path)).size > MAX_INPUT) throw new Truncation("Input byte limit"); + if (extension === ".ipynb") yield* notebook(await NodeFSP.readFile(path, "utf8")); + else if (extension === ".rtf") yield* rtf((await NodeFSP.readFile(path)).toString("latin1")); + else { + const files = await archive(path); + const notes: string[] = []; + const skip = (name: string) => + notes.push(`\n[Skipped document part: ${name} (missing or invalid XML).]\n`); + const read = (name: string): XmlNode => { + try { + const data = files.get(name); + if (!data) throw new Error("Missing document part"); + return xml(data, [".odt", ".ods", ".odp"].includes(extension), extension === ".epub"); + } catch (error) { + if (error instanceof Truncation) throw error; + skip(name); + return { name: "root", attrs: {}, children: [] }; + } + }; + const relations = (name: string) => + new Map( + [...all(read(name), "Relationship")] + .filter((rel) => rel.attrs.TargetMode !== "External") + .map((rel) => [rel.attrs.Id, rel.attrs.Target]), + ); + if (extension === ".docx") yield* paragraphs(read("word/document.xml")); + else if ([".odt", ".ods", ".odp"].includes(extension)) { + const content = read("content.xml"); + if (extension === ".odt") yield* paragraphs(content); + else { + let index = 0; + for (const part of all(content, extension === ".ods" ? "table" : "page")) { + yield extension === ".ods" + ? `\n\n## Sheet: ${part.attrs.name ?? "Untitled"}\n` + : `\n\n## Slide ${++index}\n`; + if (extension === ".ods") yield* table(part); + else yield* paragraphs(part); + } + } + } else if (extension === ".xlsx") { + const workbook = read("xl/workbook.xml"); + const dateSystem = ["1", "true"].includes( + all(workbook, "workbookPr").next().value?.attrs.date1904 ?? "", + ) + ? "1904" + : "1900"; + const styles = files.has("xl/styles.xml") ? read("xl/styles.xml") : undefined; + const customFormats = new Map( + styles + ? [...all(styles, "numFmt")].map((node) => [node.attrs.numFmtId, node.attrs.formatCode]) + : [], + ); + const cellFormats = styles ? all(styles, "cellXfs").next().value : undefined; + const formats = cellFormats ? children(cellFormats) : []; + const strings = files.has("xl/sharedStrings.xml") + ? [...all(read("xl/sharedStrings.xml"), "si")] + : []; + const populatedStrings = strings.map(hasCellText); + const rels = relations("xl/_rels/workbook.xml.rels"); + for (const sheet of all(workbook, "sheet")) { + yield `\n## Sheet: ${sheet.attrs.name}\n`; + const target = rels.get(sheet.attrs["r:id"] ?? sheet.attrs.id); + if (!target) { + skip(`sheet relationship ${sheet.attrs.name}`); + continue; + } + const name = target.startsWith("/") + ? target.slice(1) + : NodePath.posix.normalize(`xl/${target}`); + const populated = (cell: XmlNode) => { + const value = all(cell, "v").next().value; + if (cell.attrs.t === "s") { + const raw = value?.children.find((child) => typeof child === "string")?.trim(); + return !!raw && !!populatedStrings[Number(raw)]; + } + if (cell.attrs.t === "inlineStr") return hasCellText(cell); + if (value && hasCellText(value)) return true; + for (const formula of all(cell, "f")) if (hasCellText(formula)) return true; + return false; + }; + let rowCount = 0; + for (const row of all(read(name), "row")) { + const cells: Array = []; + let nextColumn = 0; + let columnsTruncated = false; + for (const cell of children(row).filter((node) => node.name === "c")) { + const letters = cell.attrs.r?.match(/^[A-Z]+/)?.[0]; + const column = letters + ? [...letters].reduce((n, char) => n * 26 + char.charCodeAt(0) - 64, 0) - 1 + : nextColumn; + nextColumn = column + 1; + if (!populated(cell)) continue; + if (column >= 512) { + columnsTruncated = true; + continue; + } + cells[column] = cell; + } + if (!cells.length && !columnsTruncated) continue; + if (++rowCount > 10_000) throw new Truncation("Row limit"); + for (const [column, cell] of cells.entries()) { + if (column) yield "\t"; + if (!cell) continue; + const value = all(cell, "v").next().value; + const raw = value?.children.find((child) => typeof child === "string") ?? ""; + if (cell.attrs.t === "s") { + const shared = strings[Number(raw)]; + if (shared) yield* inCell(text(shared)); + } else if (cell.attrs.t === "inlineStr") yield* inCell(text(cell)); + else { + if (value && hasCellText(value)) yield* inCell(text(value)); + else for (const formula of all(cell, "f")) yield* inCell(text(formula)); + const formatId = formats[Number(cell.attrs.s ?? 0)]?.attrs.numFmtId; + const format = formatId ? customFormats.get(formatId) : undefined; + if ( + (cell.attrs.t === undefined || cell.attrs.t === "n") && + raw.trim() && + formatId && + formatId !== "0" + ) + yield ` [Excel numFmtId=${formatId}${format ? `; format=${format}` : ""}; date system ${dateSystem}]`; + } + } + yield "\n"; + if (columnsTruncated) throw new Truncation("Column limit"); + } + } + } else if (extension === ".pptx") { + const rels = relations("ppt/_rels/presentation.xml.rels"); + let index = 0; + for (const slide of all(read("ppt/presentation.xml"), "sldId")) { + yield `\n\n## Slide ${++index}\n`; + const target = rels.get(slide.attrs["r:id"] ?? slide.attrs.id); + if (!target) { + skip(`slide relationship ${index}`); + continue; + } + const name = target.startsWith("/") + ? target.slice(1) + : NodePath.posix.normalize(`ppt/${target}`); + yield* paragraphs(read(name)); + const slideRels = NodePath.posix.join( + NodePath.posix.dirname(name), + "_rels", + `${NodePath.posix.basename(name)}.rels`, + ); + if (!files.has(slideRels)) continue; + for (const rel of all(read(slideRels), "Relationship")) { + const target = rel.attrs.Target; + if ( + !rel.attrs.Type?.endsWith("/notesSlide") || + rel.attrs.TargetMode === "External" || + !target + ) + continue; + yield "Notes:\n"; + yield* paragraphs( + read( + target.startsWith("/") + ? target.slice(1) + : NodePath.posix.join(NodePath.posix.dirname(name), target), + ), + ); + } + } + } else if (extension === ".epub") { + const rootfile = all(read("META-INF/container.xml"), "rootfile").next().value?.attrs[ + "full-path" + ]; + if (!rootfile) { + if (!notes.length) skip("EPUB package"); + yield* notes; + return; + } + const packageXml = read(rootfile); + const items = new Map( + [...all(packageXml, "item")].map((item) => [item.attrs.id, item.attrs.href]), + ); + let index = 0; + for (const item of all(packageXml, "itemref")) { + yield `\n\n## Chapter ${++index}\n`; + const href = items.get(item.attrs.idref); + if (!href) { + skip(`chapter ${index}`); + continue; + } + let decoded: string; + try { + decoded = decodeURIComponent(href.split("#")[0]!); + } catch { + skip(`chapter ${index}`); + continue; + } + const name = NodePath.posix.normalize( + NodePath.posix.join(NodePath.posix.dirname(rootfile), decoded), + ); + yield* paragraphs(read(name)); + } + } else throw new Error("Unsupported document"); + yield* notes; + } +} + +export async function extractDocumentText(path: string, extension: string): Promise { + let result = ""; + let remaining = MAX_OUTPUT; + let cellDepth = 0; + let started = false; + let space = false; + let truncated = false; + try { + // Charge raw text before normalization so repeated whitespace also exhausts the budget. + for await (const part of document(path, extension)) { + if (part === CELL_START) { + if (cellDepth++ === 0) { + started = false; + space = false; + } else if (started) space = true; + continue; + } + if (part === CELL_END) { + cellDepth--; + if (cellDepth > 0 && started) space = true; + continue; + } + if (typeof part !== "string" && started) continue; + const raw = typeof part === "string" ? part : part.fallback; + let value = raw.slice(0, remaining).replaceAll("\0", ""); + remaining -= Math.min(raw.length, remaining); + if (cellDepth) { + value = value.replaceAll(/\s+/g, " "); + const trimmed = value.trim(); + if (trimmed) { + result += (started && (space || value.startsWith(" ")) ? " " : "") + trimmed; + started = true; + } + space = value.endsWith(" ") || (!value && space); + } else result += value; + if (remaining === 0) throw new Truncation("Output budget"); + } + } catch (error) { + if (!(error instanceof Truncation)) throw error; + truncated = true; + } + result = result.replaceAll(/\n(?:[ \t]*\n){2,}/g, "\n\n").trim(); + if (!result && !truncated) throw new Error("No text extracted"); + return result + (truncated ? TRUNCATED : ""); +} diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index dbb7a2bfc393d..e3d4f672016c0 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -52,8 +52,7 @@ const provider: ServerProvider = { describe("provider compatibility", () => { it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => { - // Every installed build fetches main's manifest, and 0.0.45 and older run the - // V1 orchestrator, which cannot drive OpenCode 2. + // Older builds fetch main's manifest; their V1 orchestrator cannot drive OpenCode 2. const opencode = ProviderDriverKind.make("opencode"); for (const [version, expected] of [ ["1.14.19", "supported"], diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts index 038899a116d08..69322915f458a 100644 --- a/apps/server/src/provider/providerCompatibility.ts +++ b/apps/server/src/provider/providerCompatibility.ts @@ -65,9 +65,7 @@ export function resolveProviderCompatibility( ); if (!policy) return undefined; const unprefixed = version?.replace(/^v/, ""); - // Cursor appends a build hash to its date; Google's ACP runtime uses a release prefix; - // Muse appends a release revision. Strip only these driver-specific forms, keeping - // semver prereleases unknown. + // Strip Cursor build hashes, Google ACP release prefixes and Muse revisions; leave semver prereleases unknown. const stable = driver === "cursor" ? unprefixed?.replace(/^(\d{4}\.\d{2}\.\d{2})-[a-f0-9]+$/, "$1") diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 43808ff37df87..75c592b7811a1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -39,10 +39,7 @@ const NATIVE_CLI_INSTANCE_ID = ProviderInstanceId.make("nativeCli"); const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode"); const encoder = new TextEncoder(); -// Pin a non-win32 platform so `resolveSpawnCommand` is a no-op and the raw -// `{ command, args }` assertions below hold deterministically on any host -// (including Windows). Windows-specific resolution is covered by the dedicated -// win32 case at the end of this suite. +// Pin Linux so raw command/args assertions bypass Windows shim resolution. const layerNonWindowsPlatform = Layer.succeed(HostProcess.Platform, "linux"); function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 43d8b1e3916ad..9dddf23579d8a 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -43,8 +43,7 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; -// Every progress publish resends the provider list to each client, so the -// installer's output is sampled rather than forwarded line by line. +// Sample installer output because every progress update resends the provider list to all clients. const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); const UPDATE_PROGRESS_MAX_LENGTH = 200; // An installer may write for minutes without a line ending; only its tail matters. @@ -98,9 +97,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { - // On Windows the update tools are batch shims (e.g. `npm` -> `npm.cmd`), which a bare - // ChildProcess.spawn cannot launch (spawn npm ENOENT); resolveSpawnCommand finds the - // real `.cmd` and routes it through the shell. + // Windows batch shims require shell dispatch through resolveSpawnCommand. const resolved = yield* resolveSpawnCommand(input.command, input.args); const child = yield* input.spawner .spawn( @@ -292,7 +289,7 @@ function makeUpdateState(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const manifestService = yield* ModelManifest.ModelManifest; @@ -425,9 +422,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ).pipe(Effect.asVoid); yield* setRunningMessage("Checking for the latest version"); - // The cached capabilities chose the lock; re-derive ownership - // now so the command that runs matches the executable as it is - // at click time, not as it was at the last health refresh. + // Recheck ownership after acquiring the cached lock in case the executable changed. const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, @@ -522,8 +517,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { verified, instanceId, ); - // A missing version alone is not held against it, since Cursor's `about` probe can - // fail transiently on a healthy binary. + // Cursor about probes can fail transiently; missing versions alone do not imply failure. const couldNotVerify = verifiedProviders.length === 0 || verifiedProviders.some((verifiedProvider) => { diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 486b7fd41046f..fd62e75cab077 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -20,9 +20,7 @@ const mergeProviderModels = ( cachedModels: ReadonlyArray, ): ReadonlyArray => { const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - // The fallback snapshot is built from current settings and already carries - // every custom model, so cached custom rows that are not in it were removed - // while the cache was stale and must not come back. + // Settings already supply current custom models; cached rows could resurrect removed ones. return [ ...fallbackModels, ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), @@ -94,10 +92,7 @@ export const hydrateCachedProvider = (input: { : hydratedProvider; }; -/** - * Cache contents must still carry matching `instanceId` + `driver` identity - * before hydration. The filename alone is not trusted as a routing key. - */ +/** Require matching instanceId and driver before hydration; filenames alone cannot authorize routing. */ export const resolveProviderStatusCachePath = Effect.fn("resolveProviderStatusCachePath")( function* (input: { readonly cacheDir: string; diff --git a/apps/server/src/provider/resetCreditCoordinator.ts b/apps/server/src/provider/resetCreditCoordinator.ts index 45532eeb0550e..189c43b54f7f6 100644 --- a/apps/server/src/provider/resetCreditCoordinator.ts +++ b/apps/server/src/provider/resetCreditCoordinator.ts @@ -1,8 +1,4 @@ -/** - * Redeeming a reset credit is an account-level action: instances that share the - * directory holding a provider's login share the credit, so their redemptions - * must serialise on that directory, not the instance. - */ +/** Instances sharing a login directory share credits; serialize redemptions on that directory. */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -20,12 +16,7 @@ interface AccountRedemptionState { export class ResetCreditCoordinator extends Context.Service< ResetCreditCoordinator, { - /** - * The key is cleared when the provider reports an outcome, or when - * `isSettled` says a failure was a final answer (such as a cooldown). Any - * other failure (timeout included) keeps it so the next attempt is the same - * attempt. - */ + /** Reuse the idempotency key until an outcome or settled failure; timeouts keep it for retry. */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, @@ -35,13 +26,11 @@ export class ResetCreditCoordinator extends Context.Service< >()("t3/provider/resetCreditCoordinator") {} /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const statesRef = yield* Ref.make>(new Map()); - // Get-or-create through one Ref.modify so two first redemptions for the - // same account cannot each install their own lock. + // Create account locks atomically so concurrent first redemptions cannot use separate locks. const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { const existing = (yield* Ref.get(statesRef)).get(accountKey); if (existing) return existing; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 8e6284e3448dc..cae8ea2c9380d 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -23,9 +23,7 @@ rl.on("line", (line) => { } const { id, method } = message; if (openServerRequests.has(id)) { - // Real codex identifies the resolved request by its item id — the key the - // runtime correlates on — so the receipt exercises the same path that - // closes the approval card in the UI. + // Match real Codex's resolved item ID so the runtime closes the corresponding approval card. const request = openServerRequests.get(id); openServerRequests.delete(id); NodeFS.appendFileSync( diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index e30acd7d6db33..5c4c84f2aa3e3 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -89,9 +89,7 @@ lines.on("line", (line) => { }) + "\n", ); }); -// Stay alive for follow-up control requests, but never outlive the -// parent: the probe aborts the SDK without awaiting the child, so an -// unconditional interval would strand this process until reboot. +// Stay alive for control requests only while the parent lives; SDK abort does not await child exit. const keepAlive = setInterval(() => {}, 1_000); lines.on("close", () => { clearInterval(keepAlive); diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index b9b6764abca7f..81c167c038d87 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -1,16 +1,33 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { vi } from "vite-plus/test"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; +import * as DocumentText from "./DocumentText.ts"; +import { extractDocumentText } from "./documentTextWorker.ts"; +import * as Cleanup from "../orchestration-v2/ResourceCleanupService.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as PreviewManager from "../preview/Manager.ts"; +import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; +import * as NodeZlib from "node:zlib"; import { appendUserInputAttachmentPaths } from "./userInputAttachments.ts"; -const layer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }).pipe( - Layer.provideMerge(NodeServices.layer), -); +vi.mock("node:child_process", { spy: true }); + +const layer = Layer.merge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-answer-paths-" }), + DocumentText.layer, +).pipe(Layer.provideMerge(NodeServices.layer)); const attachment = { type: "file" as const, id: "thread-1-00000000-0000-4000-8000-0000000000aa-txt", @@ -19,6 +36,1063 @@ const attachment = { sizeBytes: 4, }; describe("question answer paths", () => { + it.effect.each([false, true])( + "keeps unrelated files available during extraction (failure: %s)", + (failure) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = NodePath.join(config.attachmentsDir, "blocked.rtf"); + const cached = NodePath.join(config.attachmentsDir, "cached.rtf"); + NodeFS.writeFileSync(path, failure ? "invalid document" : String.raw`{\rtf1 Available}`); + NodeFS.writeFileSync(cached + ".txt", "Cached text"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + yield* Effect.gen(function* () { + const documents = yield* DocumentText.DocumentText; + const extraction = yield* documents.ensure(path).pipe(Effect.forkChild); + yield* Deferred.await(started); + const duplicate = yield* documents.ensure(path).pipe(Effect.forkChild); + expect(yield* documents.ensure(path + ".png")).toBeUndefined(); + expect(yield* documents.ensure(path + ".pdf")).toBeUndefined(); + expect(yield* documents.ensure(cached)).toBe(cached + ".txt"); + expect(NodeFS.readFileSync(cached + ".txt", "utf8")).toBe("Cached text"); + yield* Deferred.succeed(release, undefined); + const expected = failure ? undefined : path + ".txt"; + expect(yield* Fiber.join(extraction)).toBe(expected); + expect(yield* Fiber.join(duplicate)).toBe(expected); + expect(yield* documents.ensure(path)).toBe(expected); + expect(reads).toBe(1); + if (failure) { + NodeFS.writeFileSync(path + ".txt", "Recovered text"); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Recovered text"); + } else expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Available"); + }).pipe( + Effect.provide(Layer.fresh(DocumentText.layer)), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + stat: (file) => + file === path + ? Effect.gen(function* () { + reads++; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return yield* fs.stat(file); + }) + : fs.stat(file), + }), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect.each(["spawn", "read"])("retries after a transient %s failure expires", (failure) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const { execFile } = await vi.importActual("node:child_process"); + const worker = vi.spyOn(NodeChildProcess, "execFile"); + worker.mockImplementationOnce((( + file: string, + args: readonly string[], + options: NodeChildProcess.ExecFileOptionsWithStringEncoding, + callback: ( + error: NodeChildProcess.ExecFileException | null, + stdout: string, + stderr: string, + ) => void, + ) => { + if (failure === "spawn") { + throw Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" }); + } + const preload = `import fs from 'node:fs/promises'; + import { syncBuiltinESMExports } from 'node:module'; + fs.readFile = async () => { throw Object.assign(new Error('read EAGAIN'), { code: 'EAGAIN' }); }; + syncBuiltinESMExports();`; + return execFile( + file, + ["--import", `data:text/javascript,${encodeURIComponent(preload)}`, ...args], + options, + callback, + ); + }) as typeof NodeChildProcess.execFile); + return worker; + }), + (worker) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, "retry.rtf"); + NodeFS.writeFileSync(path, String.raw`{\rtf1 Available}`); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(worker).toHaveBeenCalledTimes(1); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + yield* TestClock.adjust("1 second"); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe("Available"); + expect(worker).toHaveBeenCalledTimes(2); + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(worker).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(layer)), + (worker) => Effect.sync(() => worker.mockRestore()), + ), + ); + + it.effect.each([ + ["ipynb", "{"], + ["rtf", "invalid document"], + ["rtf", String.raw`{\rtf1}`], + ["rtf", String.raw`{\rtf1\ansi\ansicpg99999 Unsupported}`], + ] as const)("caches unchanged document rejections: %s %s", ([extension, source]) => + Effect.acquireUseRelease( + Effect.sync(() => vi.spyOn(NodeChildProcess, "execFile")), + (worker) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, `invalid.${extension}`); + NodeFS.writeFileSync(path, source); + const documents = yield* DocumentText.DocumentText; + for (const seconds of [0, 1, 60]) { + yield* TestClock.adjust(`${seconds} seconds`); + expect(yield* documents.ensure(path)).toBeUndefined(); + expect(worker).toHaveBeenCalledTimes(1); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + } + }).pipe(Effect.provide(layer)), + (worker) => Effect.sync(() => worker.mockRestore()), + ), + ); + + it.effect.each([ + [ + "text symbols", + String.raw`{\rtf1\ansi Pages 1\endash 5\emdash\lquote left\rquote\ldblquote right\rdblquote\bullet\b End\b0\unknown{\*\secret\bullet hidden}}`, + "Pages 1–5—‘left’“right”•End", + ], + [ + "space and hyphen symbols", + String.raw`{\rtf1\ansi A\enspace B\emspace C\qmspace D\~E\_F\-G}`, + "A\u2002B\u2003C\u2005D\u00a0E\u2011FG", + ], + [ + "text symbol fallback", + String.raw`{\rtf1\ansi\uc10\u945\endash\emdash\enspace\emspace\qmspace\lquote\rquote\ldblquote\rdblquote\bullet Z}`, + "αZ", + ], + ["hyphen symbol fallback", String.raw`{\rtf1\ansi\uc3\u945\~\_\-Z}`, "αZ"], + ["control word", String.raw`{\rtf1\ansi A\u9\tab X}`, "A\tX"], + ["group close", String.raw`{\rtf1\ansi{\u945}Z}`, "αZ"], + ["group open", String.raw`{\rtf1\ansi\u945{Z}}`, "αZ"], + ["hex byte", String.raw`{\rtf1\ansi\u945\'3fZ}`, "αZ"], + ["escaped symbol", String.raw`{\rtf1\ansi\u945\{Z}`, "αZ"], + ["destination symbol", String.raw`{\rtf1\ansi\u945\*Z}`, "αZ"], + ["multiple units", String.raw`{\rtf1\ansi\uc4\u945?\tab\'3f\~Z}`, "αZ"], + ["binary unit", String.raw`{\rtf1\ansi\u945\bin3 abcZ}`, "αZ"], + ["large fallback", String.raw`{\rtf1\ansi\uc20\u945????????????????????Z}`, "αZ"], + ["no fallback", String.raw`{\rtf1\ansi\uc0\u945Z}`, "αZ"], + ["scoped count", String.raw`{\rtf1\ansi{\uc2\u945??}\u946?Z}`, "αβZ"], + ] as const)("extracts RTF symbols and Unicode fallbacks: %s", ([name, source, expected]) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, `${name}.rtf`); + NodeFS.writeFileSync(path, source); + expect(yield* Effect.promise(() => extractDocumentText(path, ".rtf"))).toBe(expected); + expect(yield* (yield* DocumentText.DocumentText).ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(expected); + }).pipe(Effect.provide(layer)), + ); + + it.effect.each([false, true])("limits the child heap for standalone=%s", (standalone) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const { execFile } = await vi.importActual("node:child_process"); + const probe = vi.spyOn(NodeChildProcess, "execFile"); + probe.mockImplementation((( + file: string, + _args: readonly string[], + options: NodeChildProcess.ExecFileOptionsWithStringEncoding, + callback: ( + error: NodeChildProcess.ExecFileException | null, + stdout: string, + stderr: string, + ) => void, + ) => + execFile( + file, + [ + "-e", + 'process.stdout.write(String(require("node:v8").getHeapStatistics().heap_size_limit))', + ], + options, + callback, + )) as typeof NodeChildProcess.execFile); + return probe; + }), + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = NodePath.join(config.attachmentsDir, "heap.rtf"); + NodeFS.writeFileSync(path, String.raw`{\rtf1 hello}`); + const extracted = yield* Effect.gen(function* () { + return yield* (yield* DocumentText.DocumentText).ensure(path); + }).pipe( + Effect.provide(Layer.fresh(DocumentText.layer)), + Effect.provideService(HostProcess.IsExecutable, standalone), + Effect.provideService(HostProcess.Environment, { + ...process.env, + NODE_OPTIONS: "--max-old-space-size=1024", + }), + ); + expect(extracted).toBe(path + ".txt"); + const heapLimit = Number(NodeFS.readFileSync(path + ".txt", "utf8")); + expect(heapLimit).toBeGreaterThan(192 * 1024 * 1024); + // Newer V8 versions reserve up to 192 MiB for the young generation as well. + expect(heapLimit).toBeLessThanOrEqual(384 * 1024 * 1024); + }).pipe(Effect.provide(layer)), + (probe) => Effect.sync(() => probe.mockRestore()), + ), + ); + + it.effect("extracts notebook cells and outputs once and keeps both readable paths", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const file = { + ...attachment, + id: attachment.id.replace(/txt$/, "ipynb"), + name: "survey.ipynb", + mimeType: "application/x-ipynb+json", + }; + const path = NodePath.join(config.attachmentsDir, `${file.id}.ipynb`); + const notebook = JSON.stringify({ + cells: [ + { cell_type: "markdown", source: ["# Survey\n", "Vessel: Larkspur"] }, + { + cell_type: "code", + source: ["print(43 * 7)"], + outputs: [ + { text: ["301\n"] }, + { data: { "text/plain": ["42"] } }, + { + output_type: "error", + ename: "ZeroDivisionError", + evalue: "division by zero", + traceback: [ + "\u001b[32mCell In[1], line 1\u001b[0m", + "\u001b[31m----> 1 1 / 0\u001b[0m", + "ZeroDivisionError: division by zero", + ], + }, + ], + }, + ], + }); + NodeFS.writeFileSync(path, notebook); + const answers = yield* appendUserInputAttachmentPaths({ + answers: { q: "Read this" }, + attachmentsDir: config.attachmentsDir, + attachmentsByQuestionId: { q: [file] }, + }); + expect(answers.q).toContain(`text extracted to: "${path}.txt"`); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("Vessel: Larkspur"); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("301"); + expect(NodeFS.readFileSync(`${path}.txt`, "utf8")).toContain("42"); + const extracted = NodeFS.readFileSync(`${path}.txt`, "utf8"); + expect(extracted).toContain( + "ZeroDivisionError: division by zero\nCell In[1], line 1\n----> 1 1 / 0\nZeroDivisionError: division by zero", + ); + expect(extracted).not.toContain("\u001b"); + expect(NodeFS.readFileSync(path, "utf8")).toBe(notebook); + const before = NodeFS.statSync(path + ".txt").mtimeMs; + // A changed binary would fail parsing if the cached extraction were not reused. + NodeFS.writeFileSync(path, "invalid notebook"); + const documents = yield* DocumentText.DocumentText; + const results = yield* Effect.all([documents.ensure(path), documents.ensure(path)], { + concurrency: "unbounded", + }); + expect(results).toEqual([path + ".txt", path + ".txt"]); + expect(NodeFS.statSync(path + ".txt").mtimeMs).toBe(before); + yield* Effect.gen(function* () { + const cleanup = yield* Cleanup.ResourceCleanupService; + yield* cleanup.cleanupAttachments([file.id]); + expect(NodeFS.existsSync(path)).toBe(false); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + NodeFS.writeFileSync(path + ".txt", "orphaned extraction"); + yield* cleanup.cleanupAttachments([file.id]); + expect(NodeFS.existsSync(path + ".txt")).toBe(false); + }).pipe( + Effect.provide( + Cleanup.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({}), + Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ), + ), + ), + ), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect( + "preserves corrupt documents, PDFs and unknown binaries without an extracted path", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + for (const extension of ["docx", "ipynb", "zip", "pdf"]) { + const file = { + ...attachment, + id: attachment.id.replace(/txt$/, extension), + name: `broken.${extension}`, + }; + const path = NodePath.join(config.attachmentsDir, `${file.id}.${extension}`); + NodeFS.writeFileSync(path, "invalid document"); + const answers = yield* appendUserInputAttachmentPaths({ + answers: {}, + attachmentsDir: config.attachmentsDir, + attachmentsByQuestionId: { q: [file] }, + }); + expect(answers.q).toContain(path); + expect(answers.q).not.toContain("text extracted"); + expect(NodeFS.existsSync(`${path}.txt`)).toBe(false); + expect(NodeFS.readFileSync(path, "utf8")).toBe("invalid document"); + } + }).pipe(Effect.provide(layer)), + ); + + it.effect("preserves office structure and EPUB reading order with bounded archives", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const cases = [ + { + ext: "docx", + bounded: true, + parts: { + "word/document.xml": "

Readable despite embedded media

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

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

Repeated value

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

Kept

\n

\t

\n

\t

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

\n

+

Displayed

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

UTF-16 chapter: café 日本語

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

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

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

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

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

Healthy part

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

StartUnneeded

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

Slide text

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

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

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

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

Unneeded slide

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

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

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

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

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

Last chapter

Price 10 8

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

First chapter

Larkspur

Direct div
Direct section
  • Direct item emphasis trailing
  • Account: A
    Balance: 731Currency: USD
    HeadingTotal
    Important value731
    Before
    Inner
    After
    Neighbor
    ", + }, + expected: [ + "## Chapter 1", + "# First chapter", + "Larkspur", + "Direct div", + "Direct section", + "Direct item emphasis trailing", + "Account: A\nBalance: 731\nCurrency: USD", + "Heading\tTotal", + "Important value\t731", + "Before Inner After\tNeighbor", + "## Chapter 2", + "Last chapter", + "Price 10 8", + ], + }, + ]; + for (const [caseIndex, item] of cases.entries()) { + const local: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const [name, content] of Object.entries(item.parts)) { + const data = Buffer.from(content!); + const compressed = /\.(bmp|bin)$/.test(name) ? NodeZlib.deflateRawSync(data) : data; + const filename = Buffer.from(name); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt16LE(20, 4); + header.writeUInt16LE(compressed === data ? 0 : 8, 8); + header.writeUInt32LE(NodeZlib.crc32(data), 14); + header.writeUInt32LE(compressed.length, 18); + header.writeUInt32LE(data.length, 22); + header.writeUInt16LE(filename.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt16LE(20, 4); + directory.writeUInt16LE(20, 6); + directory.writeUInt16LE(compressed === data ? 0 : 8, 10); + directory.writeUInt32LE(NodeZlib.crc32(data), 16); + directory.writeUInt32LE(compressed.length, 20); + directory.writeUInt32LE(data.length, 24); + directory.writeUInt16LE(filename.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, filename, compressed); + central.push(directory, filename); + offset += header.length + filename.length + compressed.length; + } + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(central.length / 2, 8); + end.writeUInt16LE(central.length / 2, 10); + end.writeUInt32LE(Buffer.concat(central).length, 12); + end.writeUInt32LE(offset, 16); + const path = NodePath.join(config.attachmentsDir, `structure-${caseIndex}.${item.ext}`); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + const extracted = yield* Effect.promise(() => extractDocumentText(path, `.${item.ext}`)); + let previous = -1; + for (const expected of item.expected) { + const index = extracted.indexOf(expected); + expect(index, `${item.ext}: ${expected} in ${extracted.slice(0, 500)}`).toBeGreaterThan( + previous, + ); + previous = index; + } + if ("occurrences" in item) + expect(extracted.split(item.occurrences.text).length - 1).toBe(item.occurrences.count); + if ("absent" in item) + for (const absent of item.absent) expect(extracted).not.toContain(absent); + if (("bounded" in item && item.bounded) || item.ext === "epub") { + expect(extracted.length).toBeLessThan(1_000_100); + expect(extracted.match(/\[Extracted text truncated/g)?.length ?? 0).toBeLessThanOrEqual( + 1, + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(extracted); + } + if (item.ext === "docx") { + end.writeUInt16LE(2049, 8); + end.writeUInt16LE(2049, 10); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".docx")).toContain("[Extracted text truncated"); + }); + end.writeUInt16LE(central.length / 2, 8); + end.writeUInt16LE(central.length / 2, 10); + central[0]!.writeUInt16LE(8, 10); + central[0]!.writeUInt32LE(33 * 1024 * 1024, 24); + NodeFS.writeFileSync(path, Buffer.concat([...local, ...central, end])); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".docx")).toContain("[Extracted text truncated"); + }); + } + } + const path = NodePath.join(config.attachmentsDir, "unicode.rtf"); + NodeFS.writeFileSync( + path, + String.raw`{\rtf1\ansi{\fonttbl hidden}Hello\tab world\par Unicode \u945?{\*\secret hidden}}`, + ); + const extracted = yield* Effect.promise(() => extractDocumentText(path, ".rtf")); + expect(extracted).toContain("Hello\tworld\nUnicode α"); + expect(extracted).not.toContain("hidden"); + + for (const [name, content, expected] of [ + [ + "cyrillic-escaped", + String.raw`{\rtf1\ansi\ansicpg1251 \'cf\'f0\'e8\'e2\'e5\'f2 {\ansicpg1252 \'e9} \'cc\'ee\'f1\'ea\'e2\'e0}`, + "Привет é Москва", + ], + [ + "cyrillic-raw", + Buffer.concat([ + Buffer.from(String.raw`{\rtf1\ansi\ansicpg1251 `), + Buffer.from([0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2]), + Buffer.from("}"), + ]), + "Привет", + ], + ] as const) { + const encodedPath = NodePath.join(config.attachmentsDir, `${name}.rtf`); + NodeFS.writeFileSync(encodedPath, content); + expect(yield* Effect.promise(() => extractDocumentText(encodedPath, ".rtf"))).toBe( + expected, + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(encodedPath)).toBe(encodedPath + ".txt"); + expect(NodeFS.readFileSync(encodedPath + ".txt", "utf8")).toBe(expected); + } + const notebookPath = NodePath.join(config.attachmentsDir, "limited.ipynb"); + NodeFS.writeFileSync( + notebookPath, + JSON.stringify({ cells: Array.from({ length: 10_001 }, () => ({ source: "Kept" })) }), + ); + const notebookLimited = yield* Effect.promise(() => + extractDocumentText(notebookPath, ".ipynb"), + ); + expect(notebookLimited).toContain("## Cell 10000"); + expect(notebookLimited).not.toContain("## Cell 10001"); + expect(notebookLimited.match(/\[Extracted text truncated/g)).toHaveLength(1); + expect(yield* (yield* DocumentText.DocumentText).ensure(notebookPath)).toBe( + notebookPath + ".txt", + ); + expect(NodeFS.readFileSync(notebookPath + ".txt", "utf8")).toBe(notebookLimited); + const errorPath = NodePath.join(config.attachmentsDir, "limited-error.ipynb"); + NodeFS.writeFileSync( + errorPath, + JSON.stringify({ + cells: [ + { + cell_type: "code", + source: "1 / 0", + outputs: [ + { + output_type: "error", + ename: "ZeroDivisionError", + evalue: "division by zero", + traceback: ["\u001b[31m" + "trace".repeat(250_000) + "\u001b[0m", "After limit"], + }, + ], + }, + ], + }), + ); + const errorLimited = yield* Effect.promise(() => extractDocumentText(errorPath, ".ipynb")); + expect(errorLimited).toContain("ZeroDivisionError: division by zero\ntrace"); + expect(errorLimited.length).toBeLessThan(1_000_100); + expect(errorLimited.match(/\[Extracted text truncated/g)).toHaveLength(1); + expect(errorLimited).not.toContain("After limit"); + expect(errorLimited).not.toContain("\u001b"); + const deepPath = NodePath.join(config.attachmentsDir, "deep.rtf"); + NodeFS.writeFileSync( + deepPath, + String.raw`{\rtf1 Kept` + "{".repeat(128) + "Lost" + "}".repeat(129), + ); + const deepLimited = yield* Effect.promise(() => extractDocumentText(deepPath, ".rtf")); + expect(deepLimited).toBe("Kept\n\n[Extracted text truncated at document extraction limits.]"); + const unsupportedPath = NodePath.join(config.attachmentsDir, "unsupported-codepage.rtf"); + NodeFS.writeFileSync(unsupportedPath, String.raw`{\rtf1\ansi\ansicpg932 \'82\'a0}`); + yield* Effect.promise(async () => { + await expect(extractDocumentText(unsupportedPath, ".rtf")).rejects.toThrow( + "Unsupported RTF code page", + ); + }); + expect(yield* (yield* DocumentText.DocumentText).ensure(unsupportedPath)).toBeUndefined(); + expect(NodeFS.existsSync(unsupportedPath + ".txt")).toBe(false); + NodeFS.writeFileSync(path, String.raw`{\rtf1 ` + "x".repeat(1_000_100) + "}"); + expect(yield* Effect.promise(() => extractDocumentText(path, ".rtf"))).toContain( + "[Extracted text truncated", + ); + NodeFS.writeFileSync(path, String.raw`{\rtf1 First` + " ".repeat(1_000_100) + "Last}"); + const whitespaceTruncated = yield* Effect.promise(() => extractDocumentText(path, ".rtf")); + expect(whitespaceTruncated).toBe( + "First\n\n[Extracted text truncated at document extraction limits.]", + ); + const documents = yield* DocumentText.DocumentText; + expect(yield* documents.ensure(path)).toBe(path + ".txt"); + expect(NodeFS.readFileSync(path + ".txt", "utf8")).toBe(whitespaceTruncated); + NodeFS.truncateSync(path, 17 * 1024 * 1024); + yield* Effect.promise(async () => { + expect(await extractDocumentText(path, ".rtf")).toContain("[Extracted text truncated"); + }); + }).pipe(Effect.provide(layer)), + ); it.effect("keeps selected values intact and appends an actual readable server path", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/provider/userInputAttachments.ts b/apps/server/src/provider/userInputAttachments.ts index e1620006621b4..96ea22a2690b3 100644 --- a/apps/server/src/provider/userInputAttachments.ts +++ b/apps/server/src/provider/userInputAttachments.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as DocumentText from "./DocumentText.ts"; class UserInputAttachmentError extends Schema.TaggedError()( "UserInputAttachmentError", { @@ -23,6 +24,7 @@ export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachme }) { const answers = new Map(Object.entries(input.answers)); const fs = yield* FileSystem.FileSystem; + const documents = yield* DocumentText.DocumentText; for (const [questionId, attachments] of Object.entries(input.attachmentsByQuestionId ?? {})) { if (attachments.length === 0) continue; const references: string[] = []; @@ -46,8 +48,9 @@ export const appendUserInputAttachmentPaths = Effect.fn("appendUserInputAttachme issue: `Attachment '${attachment.name}' is no longer available. Attach it again.`, }); } + const extracted = yield* documents.ensure(path); references.push( - `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}`, + `Attached ${attachment.type} ${quoteReference(attachment.name)}: ${quoteReference(path)}${extracted ? ` (text extracted to: ${quoteReference(extracted)})` : ""}`, ); } const answer = answers.get(questionId); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 22a07e2a23171..63323b6122d20 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -24,16 +24,10 @@ export function fromProviders( }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe(Effect.map((instance) => instance.pullRequests)), - ); - return fromProviders( - drivers.filter((provider): provider is PullRequestProviderApi => provider !== null), - ); + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return fromProviders(drivers.flatMap(({ instance }) => instance.pullRequests ?? [])); }); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 8d9691fd79e30..649a1cea80d3f 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -24,8 +24,7 @@ import * as ServerConfig from "../config.ts"; import { forkParked } from "../serverActivation.ts"; const CONCURRENT_READS = 512; -// Persistence prefixes every entry key with the store id, so each entry file -// name starts with it. The scope `revisions` file does not. +// Entry filenames have the store-id prefix; the scope revisions file does not. const STORE_ID = "pr-v2"; const ENTRY_FILE_NAME = new RegExp(`^${STORE_ID}[0-9a-f]{64}$`); /** diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts index 76f703123c271..b4dd965570a9f 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.test.ts @@ -94,6 +94,7 @@ const makeRepositories = Effect.gen(function* () { const detail = (workspaceRoot: string, headSha: string): PullRequestDetail => ({ + provider: "github", number: 7, title: "Add the feature", body: "Adds the feature.", @@ -385,13 +386,7 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic name: "a head no source has", reportedHead: () => "e".repeat(40), shadow: "none", - expected: [ - "local objects:", - "the commit:", - "branch feature:", - "refs/pull/7/head:", - "refs/merge-requests/7/head:", - ], + expected: ["local objects:", "the commit:", "branch feature:", "refs/pull/7/head:"], }, ])("refuses to write a guide for $name", ({ reportedHead, shadow, expected }) => Effect.scoped( @@ -415,6 +410,8 @@ it.layer(layerBase, { excludeTestServices: true })("PullRequestReviewGuideServic assert.strictEqual(result.run?._tag, "Failed"); const detail = result.run?._tag === "Failed" ? result.run.detail : ""; for (const part of expected) assert.include(detail, part); + // Only the host's own head ref is tried. + assert.notInclude(detail, "refs/merge-requests/"); assert.deepStrictEqual(run.observed, []); assert.isFalse(run.fetchedRefs.some((ref) => ref.startsWith("-"))); const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts index edd5dd2c72865..e0dc7309289b6 100644 --- a/apps/server/src/pullRequest/PullRequestReviewGuideService.ts +++ b/apps/server/src/pullRequest/PullRequestReviewGuideService.ts @@ -24,7 +24,7 @@ import { } from "@t3tools/contracts"; import type { ReviewGuideStackLayer } from "@t3tools/provider-core/server/textGeneration"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as ServerConfig from "../config.ts"; import * as PullRequestReviewGuides from "../persistence/PullRequestReviewGuides.ts"; @@ -75,8 +75,7 @@ const make = Effect.gen(function* () { const serviceScope = yield* Effect.scope; const runs = yield* Ref.make>(new Map()); let runCounter = 0; - // Claiming a run and storing a finished guide take turns, so a run that still owns its pull - // request when it checks is the one whose guide is stored: a newer claim cannot land between. + // Serialize claims and guide writes so a newer claim cannot steal ownership between checking and storing. const ownership = yield* Semaphore.make(1); const keyFor = Effect.fn("PullRequestReviewGuideService.keyFor")(function* ( @@ -104,7 +103,7 @@ const make = Effect.gen(function* () { { projectId: ProjectId.make("own"), number: key.number, - repository: sourceControlRepositorySelector(identity) ?? "", + repository: sourceControlDefinitions.repositorySelector(identity) ?? "", }, identity, ); @@ -195,8 +194,7 @@ const make = Effect.gen(function* () { args: ["fetch", "--quiet", "--no-tags", "--", remote, ...args], timeoutMs: 5 * 60_000, }); - // The host names the base branch. A name git rejects, or one that would change the refspec - // (a leading `+`, a `:`), is refused before it reaches `git fetch`. + // Reject invalid branch names and refspec operators before fetching host-provided names. yield* git .execute({ operation: "PullRequestReviewGuideService.checkBaseBranch", @@ -213,8 +211,7 @@ const make = Effect.gen(function* () { }), ), ); - // Fetched into a ref of this run's own: FETCH_HEAD is shared by every fetch in the - // workspace, so a concurrent run could otherwise measure this change from its base. + // FETCH_HEAD is workspace-global; isolate this run's base from concurrent fetches. const baseRef = `refs/t3/review-guide/${runId}/base`; yield* fetch([`+refs/heads/${detail.baseBranch}:${baseRef}`]); yield* Effect.addFinalizer(() => @@ -239,7 +236,7 @@ const make = Effect.gen(function* () { detail: `The host reported a head for #${number} that is not a commit hash.`, }); } - // Not redundant: rev-parse prefers a branch or tag named like a short hash over the commit. + // rev-parse prefers same-named branches/tags over short hashes. const resolveHead = git.resolveCommit({ cwd: workspaceRoot, revision: sha }).pipe( Effect.filterOrFail( ({ commitSha }) => commitSha.startsWith(sha), @@ -251,6 +248,7 @@ const make = Effect.gen(function* () { ), ); const fetchAndResolve = (ref: string) => fetch([ref]).pipe(Effect.andThen(resolveHead)); + const headRef = sourceControlDefinitions.get(detail.provider).changeRequestHeadRef?.(number); const branchRef = `refs/heads/${branch}`; const checkBranch = git.execute({ operation: "PullRequestReviewGuideService.checkHeadBranch", @@ -266,11 +264,7 @@ const make = Effect.gen(function* () { source: `branch ${branch}`, head: checkBranch.pipe(Effect.andThen(fetchAndResolve(branchRef))), }, - { source: `refs/pull/${number}/head`, head: fetchAndResolve(`refs/pull/${number}/head`) }, - { - source: `refs/merge-requests/${number}/head`, - head: fetchAndResolve(`refs/merge-requests/${number}/head`), - }, + ...(headRef === undefined ? [] : [{ source: headRef, head: fetchAndResolve(headRef) }]), ]; const failures: Array = []; for (const { source, head } of sources) { @@ -336,8 +330,7 @@ const make = Effect.gen(function* () { .execute({ operation: "PullRequestReviewGuideService.readChange", cwd, - // Fixed prefixes: a user's diff.noPrefix or custom prefixes would hide the headers the - // parser reads. + // Override user diff prefixes so the parser can find headers. args: [ "-c", "core.quotePath=false", @@ -466,9 +459,7 @@ const make = Effect.gen(function* () { headSha: currentHeadSha, startedAt: yield* nowIso, }; - // Claimed atomically, so two readers pressing Generate start one run between them. The head - // is read again under the claim: a press that read an older head before a push must not - // replace a run another press started for the new one, so it asks to be pressed again. + // Claim atomically and re-read the head so stale clicks cannot replace a newer head's run. const claimed = yield* ownership.withPermits(1)( pullRequests.detail(input).pipe( Effect.flatMap((latest) => diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c59805faedeef..eb338dacdc49a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,5 @@ -import { SourceControlProviderKind, VcsProcessExitError } from "@t3tools/contracts"; +import { SourceControlProviderKind } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -10,9 +9,7 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Redacted from "effect/Redacted"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/http"; import * as TestClock from "effect/testing/TestClock"; import type { OrchestrationProjectShell, @@ -30,21 +27,10 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabPullRequestProvider from "@t3tools/source-control-gitlab/server/GitLabPullRequestProvider"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketPullRequestProvider from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestProvider"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubPullRequestProvider from "@t3tools/source-control-github/server/GitHubPullRequestProvider"; -import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota"; -import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import { fakePullRequestProvider } from "@t3tools/source-control-testing/fakeSourceControlDriver"; import { PullRequestProviderError, type ProviderChangeRequest, @@ -77,7 +63,7 @@ function project(input: { remoteName: "origin", remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, - provider: input.provider ?? "github", + provider: SourceControlProviderKind.make(input.provider ?? "github"), displayName: input.repository, }, } @@ -137,7 +123,7 @@ it.effect("does not wait for an in-flight detail read to display a preview", () const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Deferred.succeed(detailStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseDetail)), @@ -181,53 +167,30 @@ const FULL_REVIEW: PullRequestReviewCapabilities = { const FULL_REVIEWERS: PullRequestReviewerCapabilities = { request: true, listCandidates: true }; -function fakeProvider( - kind: SourceControlProviderKind, - overrides: Partial = {}, -): PullRequestProviderApi { - return { - kind, - capabilities: { - diff: true, - comment: true, - actions: ["merge", "ready", "draft", "close", "reopen"], - mergeMethods: ["merge"], - search: true, - reactions: true, - review: FULL_REVIEW, - reviewers: FULL_REVIEWERS, - edit: { changeRequest: true, comment: true }, - }, - getViewer: () => Effect.succeed("bilal"), - getViewerPermissions: () => - Effect.succeed({ - actions: ["merge", "ready", "draft", "close", "reopen"], - comment: true, - resolve: true, - verdicts: ["comment", "approve", "request-changes"], - requestReviewers: true, - }), - listChangeRequests: () => Effect.succeed({ items: [], truncated: false, continues: true }), - getChangeRequest: () => Effect.die("unused"), - getChangeRequestActivity: () => Effect.die("unused"), - getDiff: () => Effect.die("unused"), - runAction: () => Effect.void, - updateChangeRequest: () => Effect.void, - comment: () => Effect.void, - updateComment: () => Effect.void, - submitReview: () => Effect.void, - replyToThread: () => Effect.void, - setThreadResolution: () => Effect.void, - setReaction: () => Effect.void, - listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), - setReviewerRequest: () => Effect.void, - ...(kind === "github" ? { mergeMessageRewrite: (message: string) => message } : {}), - ...(kind === "azure-devops" - ? { repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey } - : {}), - ...overrides, - }; -} +// Like Azure DevOps, whose repository names repeat across organizations and projects. +const keyedByCanonicalKey = { + repositoryKey: ({ canonicalKey }: { readonly canonicalKey: string }) => canonicalKey, +}; + +/** A host whose SSH remotes name an alias, so discovery says which server serves them. */ +const sshAliasHost = { + resolveRemoteWebHost: ({ cwd, context, resolveContext }) => + isSshRemoteUrl(context.remoteUrl) + ? resolveContext({ + cwd, + context: { + ...context, + provider: { kind: SourceControlProviderKind.make("unknown"), name: "", baseUrl: "" }, + }, + }).pipe( + Effect.map((resolved) => + resolved === null || resolved.provider.kind === "unknown" + ? null + : new URL(resolved.provider.baseUrl).host, + ), + ) + : Effect.succeed(null), +} as SourceControlProvider["Service"]; function makeService(input: { readonly settings?: ServerSettings; @@ -236,8 +199,7 @@ function makeService(input: { readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; readonly resolveRepositoryIdentity?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"]["resolve"]; }) { - // Built into the test's own scope rather than provided call by call: the marks store owns a - // database, and `Effect.provide` would close it the moment the service was handed back. + // Build in the test scope; per-call Effect.provide would close the marks database on service acquisition. return Effect.flatMap( Layer.build( Layer.mergeAll( @@ -252,6 +214,10 @@ function makeService(input: { resolveLink: () => undefined, resolveHandle: input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), + get: (kind) => + Effect.succeed( + kind === "forgejo" ? sshAliasHost : ({} as SourceControlProvider["Service"]), + ), }), Layer.mock(ProjectService.ProjectService)({ listShells: (options) => @@ -291,7 +257,7 @@ it.effect("routes and caches Forgejo summaries separately for case-distinct inst }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("forgejo"), { + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { getChangeRequestSummary: (input) => Effect.sync(() => { requests.push(input.repository); @@ -339,7 +305,7 @@ it.effect("caches case-distinct Forgejo instance paths apart when one checkout s }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("forgejo"), { + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { getChangeRequestSummary: (input) => Effect.sync(() => { requests.push(input.repository); @@ -385,7 +351,7 @@ it.effect("lists GitHub Enterprise PRs for a stored unknown repository after hos }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host, repository }) => { assert.strictEqual(host, "code.example.test"); assert.strictEqual(repository, "team/project"); @@ -441,7 +407,7 @@ it.effect("derives a legacy repository host after refining its provider", () => } as unknown as OrchestrationProjectShell; const service = yield* makeService({ projects: [legacy], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ context }) => Effect.succeed({ context: { @@ -472,7 +438,7 @@ it.effect("tries another checkout when provider refinement remains unknown", () }); const service = yield* makeService({ projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], - providers: [fakeProvider(SourceControlProviderKind.make("gitlab"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("gitlab"))], resolveHandle: ({ cwd, context }) => { asked.push(cwd); return cwd === "/gone" @@ -514,7 +480,7 @@ it.effect("reads nothing from a host with no implementation, but reports it", () }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => { listed.push(input.repository); return Effect.succeed({ @@ -553,7 +519,7 @@ it.effect("says where each repository carries on, and from nothing it has run ou project({ id: "p2", title: "web", workspaceRoot: "/b", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => Effect.succeed({ items: [changeRequest(1, "2026-07-02T00:00:00Z")], @@ -586,7 +552,8 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, listChangeRequests: () => Effect.succeed({ items: [changeRequest(7, "2026-07-02T00:00:00Z")], @@ -606,80 +573,63 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" }), ); -it.effect("continues Bitbucket listings through tied timestamps and terminates", () => - Effect.gen(function* () { - const boundary = "2026-10-12T00:00:00.000Z"; - const older = "2026-10-11T00:00:00.000Z"; - const rows = [1, 2, 3, 4, 5].map((id) => ({ - id, - title: `Pull request ${id}`, - state: "OPEN", - created_on: older, - updated_on: id <= 3 ? boundary : older, - source: { branch: { name: `feat/${id}` } }, - destination: { branch: { name: "main" } }, - links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${id}` } }, - })); - const provider = yield* BitbucketPullRequestProvider.make.pipe( - Effect.provide( - BitbucketPullRequestApi.layer.pipe( - Layer.provide( - Layer.mock(BitbucketApi.BitbucketApi)({ - request: ({ url }) => { - if (url === "/user") { - return Effect.succeed({ - body: JSON.stringify({ nickname: "bilal" }), - truncated: false, - }); - } - const filter = - new URL(url, "https://api.bitbucket.org").searchParams.get("q") ?? ""; - const updatedBefore = /updated_on <= (\S+)/.exec(filter)?.[1]; - const excluded = new Set( - Array.from(filter.matchAll(/id != (\d+)/g), (match) => Number(match[1])), - ); - const values = rows.filter( +it.effect( + "continues a timestamp-paged host's listings through tied timestamps and terminates", + () => + Effect.gen(function* () { + const boundary = "2026-10-12T00:00:00.000Z"; + const older = "2026-10-11T00:00:00.000Z"; + const rows = [1, 2, 3, 4, 5].map((number) => + changeRequest(number, number <= 3 ? boundary : older), + ); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/w", + repository: "acme/web", + provider: "bitbucket", + host: "bitbucket.org", + }), + ], + providers: [ + // Pages the way Bitbucket does: inclusively by update time, excluding rows already sent. + fakePullRequestProvider(SourceControlProviderKind.make("bitbucket"), { + listChangeRequests: ({ cursor, limit }) => + Effect.sync(() => { + const seen = new Set(cursor?.seenAt ?? []); + const matching = rows.filter( (row) => - (updatedBefore === undefined || row.updated_on <= updatedBefore) && - !excluded.has(row.id), + (cursor === undefined || row.updatedAt <= cursor.updatedBefore) && + !seen.has(row.number), ); - return Effect.succeed({ body: JSON.stringify({ values }), truncated: false }); - }, - }), - ), - ), - ), - ); - const service = yield* makeService({ - projects: [ - project({ - id: "p1", - title: "web", - workspaceRoot: "/w", - repository: "acme/web", - provider: "bitbucket", - host: "bitbucket.org", - }), - ], - providers: [provider], - }); - let page = yield* service.list({ state: "open", limit: 2 }); - assert.deepStrictEqual( - page.entries.map((entry) => entry.number), - [1, 2], - ); - for (const expected of [[3, 4], [5]]) { - assert.isTrue(page.truncated); - assert.isNotEmpty(page.nextCursors); - page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + return { + items: matching.slice(0, limit), + truncated: matching.length > limit, + continues: true, + }; + }), + }), + ], + }); + let page = yield* service.list({ state: "open", limit: 2 }); assert.deepStrictEqual( page.entries.map((entry) => entry.number), - expected, + [1, 2], ); - } - assert.isFalse(page.truncated); - assert.deepStrictEqual(page.nextCursors, {}); - }), + for (const expected of [[3, 4], [5]]) { + assert.isTrue(page.truncated); + assert.isNotEmpty(page.nextCursors); + page = yield* service.list({ state: "open", limit: 2, cursors: page.nextCursors }); + assert.deepStrictEqual( + page.entries.map((entry) => entry.number), + expected, + ); + } + assert.isFalse(page.truncated); + assert.deepStrictEqual(page.nextCursors, {}); + }), ); it.effect("keeps a row already sent at the boundary instant from arriving twice", () => @@ -689,7 +639,7 @@ it.effect("keeps a row already sent at the boundary instant from arriving twice" project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -726,7 +676,7 @@ it.effect("keeps the earlier exclusions when a slice ends on the instant it bega project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -762,7 +712,7 @@ it.effect("calls a transient viewer failure a failed operation, not a signed-out project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ @@ -796,7 +746,7 @@ it.effect("reports an unusable host over a merely failing one", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.fail( new PullRequestProviderError({ @@ -807,7 +757,11 @@ it.effect("reports an unusable host over a merely failing one", () => }), ), }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + unavailableRequirement: { + missing: "Install glab.", + unauthenticated: "Run glab auth login.", + }, getViewer: () => Effect.fail(unusable(SourceControlProviderKind.make("gitlab"), "missing-tool")), }), @@ -817,7 +771,10 @@ it.effect("reports an unusable host over a merely failing one", () => const error = yield* Effect.flip(service.list({ state: "open" })); assert.strictEqual(error._tag, "PullRequestUnavailableError"); - assert.strictEqual(error.message.includes("glab"), true); + if (error._tag === "PullRequestUnavailableError") { + assert.strictEqual(error.requirement, "Install glab."); + } + assert.strictEqual(error.message, "Install glab."); }), ); @@ -835,7 +792,7 @@ it.effect("tells two hosts of one kind apart in the switcher and the filter", () }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ host }) => Effect.succeed({ items: host === "ghe.example.com" ? [changeRequest(2, "2026-07-05T00:00:00Z")] : [], @@ -877,7 +834,7 @@ it.effect("reads a repository once when several worktrees share it", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => { calls += 1; return Effect.succeed({ @@ -905,7 +862,7 @@ it.effect("keeps healthy repositories when one of them cannot be read", () => project({ id: "p2", title: "broken", workspaceRoot: "/b", repository: "pingdotgg/broken" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: (input) => input.repository === "pingdotgg/broken" ? Effect.fail(requestFailed) @@ -956,7 +913,7 @@ it.effect("offers each label once across repositories, despite one that cannot b }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listRepositoryLabels: (input) => { reads.push(input.repository); return input.repository === "pingdotgg/broken" @@ -974,7 +931,7 @@ it.effect("offers each label once across repositories, despite one that cannot b }, }), // A host with no repository labels is left out rather than failing the answer. - fakeProvider(SourceControlProviderKind.make("gitlab")), + fakePullRequestProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -997,7 +954,7 @@ it.effect("puts the author a query names exactly ahead of those it only begins", project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { searchRepositoryAuthors: (input) => { queries.push(input.query); return Effect.succeed({ @@ -1033,7 +990,7 @@ it.effect("tries another workspace on the same host for the viewer", () => project({ id: "p2", title: "healthy", workspaceRoot: "/healthy", repository: "acme/two" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => input.cwd === "/healthy" ? Effect.succeed("bilal") @@ -1069,7 +1026,7 @@ it.effect("routing verifies the current account on the requested host without ca }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: (input) => { assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" }); return Effect.succeed({ @@ -1106,7 +1063,7 @@ it.effect("refuses an action the host never claimed it could run", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1139,6 +1096,41 @@ it.effect("refuses an action the host never claimed it could run", () => }), ); +it.effect( + "asks to remove agent credits on merge only from a host that rewrites merge messages", + () => + Effect.gen(function* () { + const asked: Array = []; + const runAction: PullRequestProviderApi["runAction"] = (input) => + Effect.sync(() => { + asked.push(input.removeAgentCreditsOnMerge); + }); + for (const rewrites of [true, false]) { + const service = yield* makeService({ + settings: { ...DEFAULT_SERVER_SETTINGS, removeAgentCreditsOnMerge: true }, + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + runAction, + getChangeRequestSummary: () => + Effect.succeed(changeRequest(1, "2026-09-03T00:00:00Z")), + ...(rewrites ? { mergeMessageRewrite: (message: string) => message } : {}), + }), + ], + }); + yield* service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "merge", + }); + } + assert.deepStrictEqual(asked, [true, undefined]); + }), +); + it.effect("publishes a merge for immediate settlement only after host confirmation", () => Effect.scoped( Effect.gen(function* () { @@ -1151,7 +1143,7 @@ it.effect("publishes a merge for immediate settlement only after host confirmati project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => confirmationFails ? Effect.fail( @@ -1208,7 +1200,7 @@ it.effect("refreshes every reader before a queued merge confirmation finishes", project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.gen(function* () { yield* Deferred.succeed(confirmationStarted, undefined); @@ -1249,7 +1241,7 @@ it.effect("runs branch deletion only when the host and viewer support it, never let actions = 0; let supported = false; let allowed = true; - const base = fakeProvider(SourceControlProviderKind.make("github")); + const base = fakePullRequestProvider(SourceControlProviderKind.make("github")); const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ @@ -1314,109 +1306,41 @@ it.effect("runs branch deletion only when the host and viewer support it, never }), ); -it.effect("keeps branch deletion on the credential that passed source repository permissions", () => +it.effect("checks and deletes a branch inside one verified credential", () => Effect.gen(function* () { - let activeToken = "account-a"; - const requests: Array<{ operation: string; authorization: string | undefined }> = []; - const credentials = Layer.succeed( - GitHubCredentials.GitHubCredentials, - GitHubCredentials.GitHubCredentials.of({ - get: (host) => - Effect.sync(() => ({ - host, - token: Redacted.make(activeToken), - source: "gh" as const, - fingerprint: `${host}:${activeToken}`, - })), - invalidate: () => Effect.void, - }), - ); - const http = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.sync(() => { - const operation = request.url.endsWith("/user") - ? "user" - : request.body._tag === "Uint8Array" - ? new TextDecoder().decode(request.body.body) - : ""; - requests.push({ operation, authorization: request.headers["authorization"] }); - let body; - if (operation === "user") { - body = { id: activeToken === "account-a" ? 101 : 202, login: activeToken }; - } else if (operation.includes("query PullRequestBranchToDelete")) { - body = { - data: { - repository: { - pullRequest: { - state: "MERGED", - headRefName: "feature", - baseRefName: "main", - headRefOid: "abc123", - headRepository: { id: "FORK_REPO" }, - }, - }, - }, - }; - } else if (operation.includes("query PullRequestSourceBranch")) { - body = { - data: { - node: { - __typename: "Repository", - defaultBranchRef: { name: "main" }, - ref: { target: { __typename: "Commit", oid: "abc123" } }, - }, - }, - }; - } else if (operation.includes("updateRefs(")) { - body = { data: { updateRefs: { clientMutationId: null } } }; - } else { - activeToken = "account-b"; - body = { - data: { - repository: { - mergeCommitAllowed: true, - squashMergeAllowed: true, - rebaseMergeAllowed: true, - autoMergeAllowed: true, - viewerPermission: "READ", - pullRequest: { - viewerCanUpdate: false, - viewerDidAuthor: false, - headRepository: { viewerPermission: "WRITE" }, - }, - }, - }, - }; - } - return HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - headers: { "content-type": "application/json" }, - }), - ); - }), - ), - ); - const provider = yield* GitHubPullRequestProvider.make.pipe( - Effect.provide( - GitHubPullRequestApi.layer.pipe( - // GraphQL reads go over `http` too, through the same credential and quota as REST. - Layer.provide( - GitHubRest.layer.pipe( - Layer.provideMerge(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubQuota.layer), - Layer.provideMerge(SourceControlRateLimit.layer), - ), - ), - Layer.provide(TestSourceControlHost.layer()), - Layer.provide(NodeServices.layer), - ), - ), - ); + let pinned = false; + const calls: Array = []; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [provider], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + capabilities: { + ...fakePullRequestProvider("github").capabilities, + actions: ["delete-branch"], + deleteBranch: true, + }, + withVerifiedCredential: (_, use) => + Effect.suspend(() => { + pinned = true; + return use({ accountId: "101", viewer: "bilal", credentialFingerprint: "a" }); + }).pipe(Effect.ensuring(Effect.sync(() => (pinned = false)))), + getViewerPermissions: () => + Effect.sync(() => { + calls.push(`permissions pinned=${pinned}`); + return { + actions: ["delete-branch"], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: false, + }; + }), + runAction: (input) => + Effect.sync(() => { + calls.push(`${input.action} pinned=${pinned}`); + }), + }), + ], }); yield* service.runAction({ projectId: "p1" as ProjectId, @@ -1424,21 +1348,7 @@ it.effect("keeps branch deletion on the credential that passed source repository number: 1, action: "delete-branch", }); - assert.strictEqual(activeToken, "account-b"); - assert.strictEqual( - requests.filter(({ operation }) => operation.includes("updateRefs(")).length, - 1, - ); - assert.deepStrictEqual( - requests.map(({ authorization }) => authorization), - [ - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - "Bearer account-a", - ], - ); + assert.deepStrictEqual(calls, ["permissions pinned=true", "delete-branch pinned=true"]); }), ); @@ -1448,7 +1358,7 @@ it.effect("refuses an action this viewer may not take, and says what access it t const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["ready", "draft", "close", "reopen"], @@ -1482,7 +1392,7 @@ it.effect("gates arming a merge for later exactly as it gates merging now", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -1543,7 +1453,7 @@ it.effect("keeps two hosts of one provider kind as two accounts", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: (input) => Effect.succeed(viewerFor[input.cwd] ?? "unknown"), listChangeRequests: () => Effect.succeed({ @@ -1578,7 +1488,7 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.sync(() => { listCalls += 1; @@ -1618,22 +1528,13 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); -for (const [] of [ - ["github", "github.com"], - ["gitlab", "gitlab.com"], - ["forgejo", "code.example.test"], - ["bitbucket", "bitbucket.org"], - ["azure-devops", "dev.azure.com"], -] as const) { -} - it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { const service = yield* makeService({ projects: [ project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), ], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service @@ -1662,7 +1563,7 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("forgejo"), { + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { getViewer: (input) => { viewers.push(input.host); assert.strictEqual(input.host, "code.example:3000"); @@ -1736,6 +1637,52 @@ it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after }), ); +it.effect("names an SSH remote's web host from a later checkout when the first cannot", () => + Effect.gen(function* () { + const hosts: Array = []; + const service = yield* makeService({ + projects: ["/first", "/second"].map((workspaceRoot, index) => + project({ + id: `p${index}`, + title: `checkout ${index}`, + workspaceRoot, + repository: "team/repo", + provider: SourceControlProviderKind.make("forgejo"), + host: "ssh.code.example", + remoteUrl: "git@ssh.code.example:team/repo.git", + }), + ), + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("forgejo"), { + listChangeRequests: (input) => { + hosts.push(input.host); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + }), + ], + resolveHandle: ({ cwd, context }) => + Effect.succeed({ + context: + cwd === "/first" + ? context! + : { + ...context!, + provider: { + kind: SourceControlProviderKind.make("forgejo"), + name: "Forgejo", + baseUrl: "http://code.example:3000", + }, + }, + provider: undefined as never, + }), + }); + + yield* service.list({ state: "open" }); + + assert.deepStrictEqual(hosts, ["code.example:3000"]); + }), +); + it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1750,7 +1697,7 @@ it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => remoteUrl: "http://code.example:4000/team/repo.git", }), ], - providers: [fakeProvider(SourceControlProviderKind.make("forgejo"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("forgejo"))], }); const failure = yield* service .summary( @@ -1778,7 +1725,7 @@ it.effect("resolves a project's repository identity when its shell has none cach const service = yield* makeService({ projects: [{ ...resolved, repositoryIdentity: null }], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed(changeRequest(7, "2026-07-02T00:00:00Z")), }), ], @@ -1802,7 +1749,7 @@ it.effect("routes a hosted reference to another repository through a project on project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); @@ -1837,7 +1784,8 @@ it.effect("routes Azure reads and writes through the requested organization's ch }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -1903,7 +1851,8 @@ it.effect.each([ target, ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(`read ${input.cwd} ${input.repository}`); @@ -1942,7 +1891,8 @@ it.effect("refuses Azure cross-organization reads and writes without its checkou }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), runAction: () => Effect.die("must not modify the wrong organization"), }), @@ -1972,7 +1922,7 @@ it.effect("prefers a matching checkout over a non-repository project's directory project({ id: "repo", title: "API", workspaceRoot: "/api", repository: "acme/api" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -1999,7 +1949,7 @@ it.effect("requires an explicit host for a non-repository project's linked PR", Effect.gen(function* () { const service = yield* makeService({ projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/home/alex" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const error = yield* service .summary( @@ -2021,7 +1971,7 @@ it.effect("refuses a review request this viewer may not make, and says what acce const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewerPermissions: () => Effect.succeed({ actions: ["ready", "draft", "close", "reopen"], @@ -2078,7 +2028,7 @@ it.effect.each(["list", "listStats"] as const)( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => lookup.pipe( Effect.as({ @@ -2126,7 +2076,7 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getViewer: () => Effect.sync(() => { viewerCalls += 1; @@ -2164,7 +2114,7 @@ it.effect("a listing narrowed to some projects is its own cache entry", () => project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories); return Effect.succeed({ @@ -2205,7 +2155,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ filters }) => Effect.gen(function* () { reads += 1; @@ -2259,7 +2209,7 @@ it.effect("does not revive old summaries when project epochs are evicted", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("body"), title }), }), ], @@ -2286,7 +2236,7 @@ it.effect("close and reopen notify subscribed readers after invalidating their c project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), runAction: (input) => @@ -2345,7 +2295,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ items: [], truncated: false, continues: true }); @@ -2361,7 +2311,7 @@ it.effect("reads a host's repositories in one search, and files the rows back un }); }, }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: ({ repository }) => { separately.push(repository); return Effect.succeed({ @@ -2397,7 +2347,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () project({ id: "p3", title: "docs", workspaceRoot: "/c", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: () => Effect.succeed({ items: [ @@ -2406,6 +2356,7 @@ it.effect("carries every repository of a slice on from the oldest row in it", () batchedChangeRequest(3, "acme/web", "2026-07-02T00:00:00Z"), ], truncated: true, + unresolved: ["acme/docs"], }), }), ], @@ -2426,7 +2377,7 @@ it.effect("carries a slice on without sending the rows it already sent", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { cursors.push(input.cursor); return Effect.succeed({ @@ -2469,7 +2420,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestsAcross: (input) => { asked.push(input.repositories.length); return Effect.succeed({ @@ -2489,7 +2440,7 @@ it.effect("reads a workspace larger than one search in chunks, and merges them", assert.strictEqual(result.entries.length, 101); }), ); -it.effect("asks on its own for a repository a search answered nothing for", () => +it.effect("asks on its own for a repository a search could not vouch for", () => Effect.gen(function* () { const separately: string[] = []; const service = yield* makeService({ @@ -2498,7 +2449,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: ({ repository }) => { separately.push(repository); return repository === "acme/docs" @@ -2509,6 +2460,7 @@ it.effect("asks on its own for a repository a search answered nothing for", () = Effect.succeed({ items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], truncated: false, + unresolved: ["acme/docs"], }), }), ], @@ -2530,6 +2482,38 @@ it.effect("asks on its own for a repository a search answered nothing for", () = ); }), ); +it.effect("believes a search's silence about a repository it vouches for", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + listChangeRequestsAcross: () => + Effect.succeed({ + items: [batchedChangeRequest(1, "acme/web", "2026-07-03T00:00:00Z")], + truncated: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(separately, []); + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1], + ); + }), +); it.effect("fills in the line counts for the rows it is given", () => Effect.gen(function* () { const asked: Array = []; @@ -2545,7 +2529,7 @@ it.effect("fills in the line counts for the rows it is given", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequestStats: (input) => { asked.push(input.changeRequests); return Effect.succeed([ @@ -2553,7 +2537,7 @@ it.effect("fills in the line counts for the rows it is given", () => ]); }, }), - fakeProvider(SourceControlProviderKind.make("gitlab")), + fakePullRequestProvider(SourceControlProviderKind.make("gitlab")), ], }); @@ -2595,7 +2579,7 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), updatedAt: revision })), getChangeRequestSummary: () => @@ -2656,9 +2640,9 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getFilesViewed: () => @@ -2722,9 +2706,9 @@ it.effect("isolates routed caches for two credentials belonging to the same acco project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getFilesViewed: () => @@ -2765,6 +2749,9 @@ it.effect("isolates routed caches for two credentials belonging to the same acco host: "github.com", expectedAccountId: "101", }; + if (operation === "checks") { + yield* service.withRoutingCredential(reference, service.detail(reference)); + } yield* service.withRoutingCredential(reference, readOperation(reference)); credential = "restricted"; for (const allowStale of [false, true]) { @@ -2784,7 +2771,7 @@ it.effect("rejects mismatched routing credentials before use and preserves actio const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), withVerifiedCredential: (_, use) => use({ @@ -2840,7 +2827,7 @@ it.effect( project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestSummary: () => Effect.succeed({ ...changeRequest(1, "2026-07-02T00:00:00Z"), @@ -2880,7 +2867,7 @@ it.effect("notifies linked threads when detail changes draft status in either di const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.sync(() => ({ ...hostedChangeRequest("body"), isDraft, updatedAt })), }), @@ -2921,7 +2908,7 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [fakeProvider(SourceControlProviderKind.make("github"))], + providers: [fakePullRequestProvider(SourceControlProviderKind.make("github"))], }); const changes = yield* service.subscribeStateChanges; for (const isDraft of [true, true, false, false, true]) { @@ -2940,6 +2927,48 @@ it.effect("deduplicates routed PR reports without dropping draft changes", () => }), ); +it.effect( + "reuses fresh detail checks without extending their refresh interval on detail cache hits", + () => + Effect.gen(function* () { + let reads = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: () => Effect.succeed(hostedChangeRequest("Description")), + getChangeRequestChecks: () => + Effect.sync(() => { + reads += 1; + return { state: "closed" as const, checks: [] }; + }), + }), + ], + }); + yield* TestClock.setTime(1_000); + const detail = yield* service.detail(reference); + expect(yield* service.checks(reference)).toEqual({ + state: detail.state, + checks: detail.checks, + observedAt: detail.observedAt, + }); + expect(reads).toBe(0); + yield* TestClock.adjust("16 seconds"); + yield* service.detail(reference); + expect(yield* service.checks(reference)).toMatchObject({ + state: "closed", + observedAt: 17_000, + }); + expect(reads).toBe(1); + yield* service.detail(reference); + expect((yield* service.checks(reference))?.state).toBe("closed"); + expect(reads).toBe(1); + }), +); + it.effect("preserves check observation time on cache hits and advances it on refresh", () => Effect.gen(function* () { let reads = 0; @@ -2947,7 +2976,7 @@ it.effect("preserves check observation time on cache hits and advances it on ref const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequestChecks: () => Effect.sync(() => { reads += 1; @@ -2974,7 +3003,7 @@ it.effect("reuses an observed merged state for strict settlement reads", () => const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.succeed({ ...hostedChangeRequest("merged body", 4), @@ -3004,7 +3033,7 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { getChangeRequest: () => Effect.gen(function* () { detailCalls += 1; @@ -3067,7 +3096,7 @@ it.effect("keeps a row of a host that ignored the filters if any name of a label }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -3130,7 +3159,7 @@ it.effect.each([false, true])( }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities, getViewerPermissions: () => Effect.succeed({ @@ -3198,7 +3227,7 @@ it.effect("refuses a way of updating a branch that the host or the viewer does n const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { diff: true, comment: true, @@ -3257,7 +3286,7 @@ it.effect("keeps reading a host's thinned-out slices until a filtered page is fu }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => { const delivered = input.cursor?.delivered ?? 0; reads.push(delivered); @@ -3304,7 +3333,7 @@ it.effect("hands a host's own resume position back on the next read", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: (input) => { resumed.push(input.cursor?.resumeAfter); return Effect.succeed({ @@ -3338,7 +3367,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { listChangeRequests: () => Effect.succeed({ items: [ @@ -3352,7 +3381,7 @@ it.effect("judges the review filter only on a host that summarises its reviews", continues: true, }), }), - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { listChangeRequests: () => Effect.succeed({ items: [changeRequest(3, "2026-07-02T00:00:00Z")], @@ -3381,9 +3410,9 @@ it.effect("partial host viewed writes invalidate marks but keep the diff cached" const service = yield* makeService({ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { + fakePullRequestProvider(SourceControlProviderKind.make("github"), { capabilities: { - ...fakeProvider(SourceControlProviderKind.make("github")).capabilities, + ...fakePullRequestProvider(SourceControlProviderKind.make("github")).capabilities, viewedFiles: "host", }, getDiff: () => @@ -3435,7 +3464,7 @@ const environmentViewedProvider = ( asked: Array>, unreadable: ReadonlySet = new Set(), ) => - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -3486,73 +3515,54 @@ const GITLAB_REFERENCE = { number: 1, }; -it.effect("tracks Forgejo viewed files through its diff and refuses truncated baselines", () => +it.effect("tracks environment viewed files by revision and refuses a baseline it cannot read", () => Effect.gen(function* () { - let alpha = "b485fe5"; - let truncated = false; - let diffReads = 0; - const provider = yield* ForgejoPullRequestProvider.make.pipe( - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - api: (input) => { - assert.strictEqual(input.host, "forge.example:3000"); - const viewer = input.path === "user"; - if (!viewer) { - assert.strictEqual(input.repository, "reviewer/project"); - assert.strictEqual(input.path, "repos/reviewer/project/pulls/1.diff"); - diffReads++; - } - return Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout: viewer - ? JSON.stringify({ login: "reviewer" }) - : truncated - ? `diff --git a/alpha.ts b/alpha.ts\nindex eb2d7c5..${alpha} 100644\n` - : [ - "diff --git a/alpha.ts b/alpha.ts", - `index eb2d7c5..${alpha} 100644`, - "diff --git a/beta.ts b/beta.ts", - "index b39e8b3..5851425 100644", - 'diff --git "a/caf\\303\\251 notes.txt" "b/caf\\303\\251 notes.txt"', - "index ffd99ce..6dd9855 100644", - "diff --git a/deleted.txt b/deleted.txt", - "deleted file mode 100644", - "index 233f5c6..0000000", - "diff --git a/old.txt b/renamed.txt", - "similarity index 100%", - "rename from old.txt", - "rename to renamed.txt", - "", - ].join("\n"), - stderr: "", - stdoutTruncated: !viewer && truncated, - stderrTruncated: false, - }); - }, - }), - ), - ); + const revisions = new Map([ + ["alpha.ts", "b485fe5"], + ["beta.ts", "5851425"], + ["café notes.txt", "6dd9855"], + ["deleted.txt", "0000000"], + ["renamed.txt", ""], + ]); + let unreadable = false; + let revisionReads = 0; const service = yield* makeService({ projects: [ project({ - id: "forgejo", - title: "on forgejo", - workspaceRoot: "/forgejo", - repository: "reviewer/project", - provider: SourceControlProviderKind.make("forgejo"), - host: "forge.example:3000", - remoteUrl: "http://forge.example:3000/reviewer/project.git", + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: SourceControlProviderKind.make("gitlab"), }), ], - providers: [provider], + providers: [ + { + ...environmentViewedProvider(revisions, []), + getFileRevisions: (input) => + Effect.suspend(() => { + revisionReads++; + return unreadable + ? Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("gitlab"), + operation: "getFileRevisions", + reason: "failed", + detail: "The diff exceeded the output limit.", + }), + ) + : Effect.succeed({ + revisions: new Map( + input.paths.map((path) => [path, revisions.get(path) ?? ""] as const), + ), + complete: true, + }); + }), + }, + ], }); - const reference = { - projectId: "forgejo" as ProjectId, - repository: "reviewer/project", - host: "forge.example:3000", - number: 1, - }; - const paths = ["alpha.ts", "beta.ts", "café notes.txt", "deleted.txt", "renamed.txt"]; + const reference = GITLAB_REFERENCE; + const paths = [...revisions.keys()]; yield* service.setFilesViewed({ ...reference, files: paths.map((path) => ({ path, viewed: true })), @@ -3561,9 +3571,9 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), new Map(paths.map((path) => [path, "viewed"])), ); - assert.strictEqual(diffReads, 1); + assert.strictEqual(revisionReads, 1); - alpha = "aabbccd"; + revisions.set("alpha.ts", "aabbccd"); yield* service.invalidate({ reference }); assert.deepStrictEqual( new Map((yield* service.filesViewed(reference)).files.map((file) => [file.path, file.state])), @@ -3577,13 +3587,13 @@ it.effect("tracks Forgejo viewed files through its diff and refuses truncated ba (yield* service.filesViewed(reference)).files.some((file) => file.path === "beta.ts"), ); - truncated = true; + unreadable = true; yield* service.invalidate({ reference }); yield* service.setFilesViewed({ ...reference, files: [{ path: "beta.ts", viewed: true }], }); - truncated = false; + unreadable = false; yield* service.invalidate({ reference }); assert.deepStrictEqual( (yield* service.filesViewed(reference)).files.find((file) => file.path === "beta.ts"), @@ -3823,7 +3833,7 @@ it.effect("finishes two presses on one file in the order they were made", () => }), ], providers: [ - fakeProvider(SourceControlProviderKind.make("gitlab"), { + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { capabilities: { diff: true, comment: true, @@ -3872,7 +3882,8 @@ const azureViewedService = (projects: ReadonlyArray) makeService({ projects, providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, capabilities: { diff: true, comment: true, @@ -4017,7 +4028,8 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ), providers: [ - fakeProvider(SourceControlProviderKind.make("azure-devops"), { + fakePullRequestProvider(SourceControlProviderKind.make("azure-devops"), { + ...keyedByCanonicalKey, listChangeRequests: (input) => Effect.sync(() => { seen.push(input.cwd); @@ -4039,38 +4051,9 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s }), ); -it.effect("pauses uncached GitLab activity after only the reactions endpoint is rate limited", () => +it.effect("pauses uncached activity on a host after one activity read is rate limited", () => Effect.gen(function* () { - let commandCalls = 0; - const provider = yield* GitLabPullRequestProvider.make.pipe( - Effect.provide( - GitLabPullRequestCli.layer.pipe( - Layer.provide(GitLabCli.layer), - Layer.provide( - TestSourceControlHost.layer({ - process: { - run: (input) => - Effect.suspend(() => { - commandCalls += 1; - return input.args[1] === "graphql" - ? Effect.fail( - new VcsProcessExitError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - exitCode: 1, - detail: "API rate limit exceeded.", - failureKind: "rate-limited", - }), - ) - : Effect.succeed(TestSourceControlHost.processOutput("[]")); - }), - }, - }), - ), - ), - ), - ); + let activityReads = 0; const service = yield* makeService({ projects: [ project({ @@ -4082,7 +4065,22 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is host: "gitlab.example.test", }), ], - providers: [provider], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("gitlab"), { + getChangeRequestActivity: () => + Effect.suspend(() => { + activityReads++; + return Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("gitlab"), + operation: "getChangeRequestActivity", + reason: "rate-limited", + detail: "API rate limit exceeded.", + }), + ); + }), + }), + ], }); const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; @@ -4090,17 +4088,12 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is _tag: "Failure", failure: { _tag: "PullRequestOperationError", - cause: { - _tag: "PullRequestProviderError", - reason: "rate-limited", - cause: { _tag: "GitLabCliRateLimitError" }, - }, + cause: { _tag: "PullRequestProviderError", reason: "rate-limited" }, }, }); - const firstCommandCalls = commandCalls; - assert.isAbove(firstCommandCalls, 0); + assert.strictEqual(activityReads, 1); - // A different merge request cannot reuse the first request's cached failure. + // A different change request cannot reuse the first request's cached failure. expect(yield* Effect.result(service.activity({ ...reference, number: 2 }))).toMatchObject({ _tag: "Failure", failure: { @@ -4115,82 +4108,357 @@ it.effect("pauses uncached GitLab activity after only the reactions endpoint is }, }, }); - assert.strictEqual(commandCalls, firstCommandCalls); + assert.strictEqual(activityReads, 1); }), ); -it.effect("expands only files the change request changes, across every slice of its diff", () => +it.effect("authorizes cold source reads with paginated paths without fetching patch hunks", () => Effect.gen(function* () { - const read: Array = []; + const pages: Array = []; + const contents: Array = []; const service = yield* makeService({ - projects: [ - project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), - ], + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], providers: [ - fakeProvider(SourceControlProviderKind.make("github"), { - getDiff: (input) => - Effect.succeed( - input.cursor === undefined - ? { - patch: [ - "diff --git a/src/old.ts b/src/new.ts", - "rename from src/old.ts", - "rename to src/new.ts", - "--- a/src/old.ts", - "+++ b/src/new.ts", - "", - ].join("\n"), - truncated: false, - nextCursor: "2", - } - : { - patch: [ - "diff --git a/gone.md b/gone.md", - "deleted file mode 100644", - "--- a/gone.md", - "+++ /dev/null", - "", - ].join("\n"), - truncated: false, - nextCursor: null, - }, - ), + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.sync(() => { + pages.push(input.cursor); + return input.cursor === undefined + ? { files: [{ oldPath: "before.ts", newPath: "after.ts" }], nextCursor: "next" } + : { files: [{ oldPath: "gone.ts", newPath: "gone.ts" }], nextCursor: null }; + }), getDiffFileContents: (input) => Effect.sync(() => { - read.push(`${input.oldPath}->${input.newPath}`); - return { oldContents: "old", newContents: "new" }; + contents.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "before", newContents: "after" }; }), }), ], }); - const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; - + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; yield* service.diffFileContents({ ...ref, changeType: "rename-changed", - oldPath: "src/old.ts", - newPath: "src/new.ts", + oldPath: "before.ts", + newPath: "after.ts", }); yield* service.diffFileContents({ ...ref, changeType: "deleted", - oldPath: "gone.md", - newPath: "gone.md", + oldPath: "gone.ts", + newPath: "gone.ts", }); - const outside = yield* service - .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) - .pipe(Effect.flip); - const halfRename = yield* service - .diffFileContents({ + for (const [oldPath, newPath] of [ + [".env", ".env"], + ["../after.ts", "../after.ts"], + ["secret.ts", "after.ts"], + ] as const) { + const error = yield* service + .diffFileContents({ ...ref, changeType: "change", oldPath, newPath }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + } + assert.deepStrictEqual(pages, [undefined, "next"]); + assert.deepStrictEqual(contents, ["before.ts->after.ts", "gone.ts->gone.ts"]); + }), +); + +it.effect.each(["commit", "credential", "expiry", "refresh"] as const)( + "keeps cold metadata membership in its original scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let credential = "broad"; + let pathReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => Effect.die("Membership must not fetch patch hunks"), + getDiffFilePaths: (input) => + Effect.gen(function* () { + assert.strictEqual(input.cwd, "/a"); + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.repository, "acme/web"); + if (++pathReads !== 1) return { files: [], nextCursor: null }; + assert.strictEqual( + input.commit, + boundary === "commit" ? "a".repeat(40) : undefined, + ); + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + files: [{ oldPath: "private.ts", newPath: "private.ts" }], + nextCursor: null, + }; + }), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const initial = boundary === "commit" ? { ...input, commit: "a".repeat(40) } : input; + const loading = yield* Effect.forkChild( + service.withRoutingCredential(initial, service.diffFileContents(initial)), + ); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + if (boundary === "refresh") yield* service.invalidate({ reference: input }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary === "credential") credential = "restricted"; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(pathReads, 2); + assert.strictEqual(contentReads, 1); + }), +); + +it.effect( + "reuses loaded oversized diff slices to expand only files the change request changes", + () => + Effect.gen(function* () { + const read: Array = []; + const diffReads: Array = []; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "t3code", + workspaceRoot: "/a", + repository: "pingdotgg/t3code", + }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: (input) => + Effect.sync(() => { + diffReads.push(input.cursor); + return input.cursor === undefined + ? { + patch: [ + "diff --git a/src/old.ts b/src/new.ts", + "rename from src/old.ts", + "rename to src/new.ts", + "--- a/src/old.ts", + "+++ b/src/new.ts", + `+${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: "2", + } + : { + patch: [ + "diff --git a/gone.md b/gone.md", + "deleted file mode 100644", + "--- a/gone.md", + "+++ /dev/null", + `-${"x".repeat(300_000)}`, + "", + ].join("\n"), + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: (input) => + Effect.sync(() => { + read.push(`${input.oldPath}->${input.newPath}`); + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "pingdotgg/t3code", number: 1 }; + + yield* service.diff(ref); + yield* service.diff({ ...ref, cursor: "2" }); + + yield* service.diffFileContents({ ...ref, - changeType: "change", - oldPath: "src/secret.ts", + changeType: "rename-changed", + oldPath: "src/old.ts", newPath: "src/new.ts", - }) - .pipe(Effect.flip); + }); + yield* service.diffFileContents({ + ...ref, + changeType: "deleted", + oldPath: "gone.md", + newPath: "gone.md", + }); + const outside = yield* service + .diffFileContents({ ...ref, changeType: "deleted", oldPath: ".env", newPath: ".env" }) + .pipe(Effect.flip); + const halfRename = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "src/secret.ts", + newPath: "src/new.ts", + }) + .pipe(Effect.flip); + + assert.strictEqual(outside._tag, "PullRequestOperationError"); + assert.strictEqual(halfRename._tag, "PullRequestOperationError"); + assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); + assert.deepStrictEqual(diffReads, [undefined, "2"]); + }), +); - assert.strictEqual(outside._tag, "PullRequestOperationError"); - assert.strictEqual(halfRename._tag, "PullRequestOperationError"); - assert.deepStrictEqual(read, ["src/old.ts->src/new.ts", "gone.md->gone.md"]); - }), +// Refresh and expiry are covered by the in-flight test below; repository and number share the ref key. +it.effect.each(["commit", "credential"] as const)( + "does not reuse loaded diff membership after changing %s", + (boundary) => + Effect.gen(function* () { + let credential = "broad"; + let diffReads = 0; + let contentReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ accountId: "101", viewer: "octocat", credentialFingerprint: credential }), + ), + getDiff: () => + Effect.sync(() => ({ + patch: + ++diffReads === 1 + ? `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n` + : "", + truncated: false, + nextCursor: null, + })), + getDiffFileContents: () => + Effect.sync(() => { + contentReads += 1; + return { oldContents: "old", newContents: "new" }; + }), + }), + ], + }); + const ref = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + }; + // Load at a commit and expand without one so the commit must be part of the membership key. + const loaded = boundary === "commit" ? { ...ref, commit: "a".repeat(40) } : ref; + yield* service.withRoutingCredential(loaded, service.diff(loaded)); + if (boundary === "credential") credential = "restricted"; + const input = { + ...ref, + changeType: "change" as const, + oldPath: "private.ts", + newPath: "private.ts", + }; + const error = yield* service + .withRoutingCredential(input, service.diffFileContents(input)) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + assert.strictEqual(contentReads, 0); + }), +); + +it.effect.each(["expiry", "commit expiry", "cache hit", "commit cache hit", "refresh"] as const)( + "keeps a loaded diff's original membership scope through an in-flight %s", + (boundary) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let diffReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: () => + Effect.gen(function* () { + if (++diffReads !== 1) return { patch: "", truncated: false, nextCursor: null }; + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { + patch: `diff --git a/private.ts b/private.ts\n--- a/private.ts\n+++ b/private.ts\n+${"x".repeat(300_000)}\n`, + truncated: false, + nextCursor: null, + }; + }), + getDiffFileContents: () => Effect.succeed({ oldContents: "old", newContents: "new" }), + }), + ], + }); + const ref = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + ...(boundary.includes("commit") ? { commit: "a".repeat(40) } : {}), + }; + const loading = yield* Effect.forkChild(service.diff(ref)); + yield* Deferred.await(started); + if (boundary === "expiry") yield* TestClock.adjust("61 seconds"); + else if (boundary === "commit expiry") yield* TestClock.adjust("11 minutes"); + else if (boundary.includes("cache hit")) yield* TestClock.adjust("30 seconds"); + else yield* service.invalidate({ reference: ref }); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(loading); + if (boundary.includes("cache hit")) { + yield* service.diff(ref); + yield* service.diff(ref); + yield* TestClock.adjust(boundary.includes("commit") ? "9 minutes" : "29 seconds"); + assert.deepStrictEqual( + yield* service.diffFileContents({ + ...ref, + changeType: "change", + oldPath: "private.ts", + newPath: "private.ts", + }), + { oldContents: "old", newContents: "new" }, + ); + assert.strictEqual(diffReads, 1); + yield* TestClock.adjust(boundary.includes("commit") ? "1 minute" : "2 seconds"); + } + const error = yield* service + .diffFileContents({ + ...ref, + changeType: "change", + oldPath: "private.ts", + newPath: "private.ts", + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(error.reason, "not-found"); + assert.strictEqual(diffReads, 2); + }), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 21ad1c1596ace..7aeb002cce5df 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,10 +1,4 @@ -import { - canonicalRepositoryKey, - normalizeSourceControlRepository, - isSshRemoteUrl, - sourceControlRepositorySelector, -} from "@t3tools/shared/sourceControl"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { isSshRemoteUrl, normalizeSourceControlRepository } from "@t3tools/shared/sourceControl"; import { parseDiffFilePaths } from "@t3tools/source-control-core/server/diffFileRevisions"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -24,8 +18,6 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { PullRequestOperationError, PullRequestUnavailableError, - pullRequestHostOf, - pullRequestProviderRequirement, resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type ProjectId, @@ -83,16 +75,18 @@ import { type ThreadPullRequestKey, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { type ProviderChangeRequest, + type ProviderDiffFilePaths, type ProviderFilterOptionPage, type ProviderListCursor, type ProviderChangeRequestWatchFingerprint, @@ -120,22 +114,13 @@ function supersedesReading(current: PullRequestReading | undefined, next: PullRe return (next.observedAt ?? -Infinity) >= (current.observedAt ?? -Infinity); } -/** - * 99 and not 100, because every provider asks its host for one row over this to probe for a next - * page: 99 requests 100, which is exactly what a page of GitHub's API serves — GraphQL refuses - * `first` over 100 with EXCESSIVE_PAGINATION and REST clamps `per_page` to it — and what GitLab - * caps `per_page` at. - */ +// Providers request one extra row; 99 stays within GitHub/GitLab's 100-row API limit. const DEFAULT_REPOSITORY_LIST_LIMIT = 99; const REPOSITORY_CONCURRENCY = 12; const REPOSITORY_SEARCH_CHUNK = 100; const FILTERED_LIST_READS = 5; -/** - * Every read leaves the process — a CLI per repository, against hosts whose limits are low - * (GitHub's search API allows ~30 requests a minute) — so answers are shared for a short while and - * concurrent identical reads share one request. - */ +// Host reads spawn CLIs and hit low quotas; briefly share results and identical in-flight reads. const LIST_CACHE_TTL = Duration.seconds(30); const DETAIL_CACHE_TTL = Duration.seconds(60); const MERGED_DETAIL_CACHE_TTL = Duration.minutes(10); @@ -150,16 +135,29 @@ const FILES_VIEWED_CACHE_TTL = Duration.seconds(15); const DIFF_STALE_WINDOW = Duration.minutes(10); const DIFF_SWEEP_INTERVAL = Duration.minutes(1); const VIEWER_CACHE_TTL = Duration.minutes(10); -const SEARCH_VISIBILITY_TTL = Duration.minutes(10); const STALE_DETAIL_WINDOW = Duration.minutes(10); const isPullRequestProviderError = Schema.is(PullRequestProviderError); const LIST_CACHE_CAPACITY = 64; const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; -const MAX_CACHED_DIFF_PATCH_BYTES = 512 * 1024; +// Each holder stays within this UTF-16 patch budget as well as its entry-count bound. +const DIFF_CACHE_PATCH_BUDGET_BYTES = 32 * 1024 * 1024; const canCacheDiff = (value: PullRequestDiffResult) => - value.patch.length * 2 <= MAX_CACHED_DIFF_PATCH_BYTES; + value.patch.length * 2 <= DIFF_CACHE_PATCH_BUDGET_BYTES; +const diffCacheEvictions = (entries: Iterable) => { + let bytes = 0; + const evictions: string[] = []; + for (const [key, value] of Array.from(entries).toReversed()) { + const size = value.patch.length * 2; + if (bytes + size > DIFF_CACHE_PATCH_BUDGET_BYTES) { + evictions.push(key); + } else { + bytes += size; + } + } + return evictions; +}; const FILES_VIEWED_CACHE_CAPACITY = 128; const VIEWER_CACHE_CAPACITY = 32; @@ -322,9 +320,7 @@ export interface SupportedProject { readonly api: PullRequestProviderApi; readonly repository: string; readonly host: string; - /** - * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. - */ + /** Unlike Azure repository names, remotes are unique across an organization. */ readonly remote: string; readonly repositoryKey: string | null; } @@ -369,10 +365,7 @@ function listCursorKey(host: string, repository: string, kind: SourceControlProv return `${host} ${normalizeSourceControlRepository(repository, kind)}`; } -/** - * A slice that ends on the same instant it began on has to keep the earlier rows excluded as - * well as its own, or the read after it would hand them over again. - */ +// Preserve exclusions across slices sharing a timestamp to avoid redelivery. function nextListCursor( previous: ListCursor | undefined, fetched: ReadonlyArray, @@ -382,10 +375,7 @@ function nextListCursor( resumeAfter?: string, ): string | null { if (fetched.length === 0) return null; - // Taken from what the host answered rather than from what survived de-duplication: a slice can - // be entirely rows already sent — a hundred change requests touched in the same second is one - // repository's boring afternoon — and reading "nothing new" as "nothing left" would end the - // walk on the instant it was stuck on, with everything older unreachable for good. + // Use all fetched rows: a slice containing only duplicates must still advance to older rows. const oldest = fetched.reduce((left, right) => (right.updatedAt < left.updatedAt ? right : left)); return listCursorAt(previous, oldest.updatedAt, fetched, cursorAdvance, resumeAfter); } @@ -409,36 +399,47 @@ function isProviderUnusable(error: PullRequestProviderError): boolean { return error.reason === "missing-tool" || error.reason === "unauthenticated"; } -function providerDetail(error: PullRequestProviderError): string { - if (!isProviderUnusable(error)) return error.detail; - return ( - pullRequestProviderRequirement( - error.provider, - error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - ) ?? error.detail - ); +type UnavailableRequirementOf = ( + kind: SourceControlProviderKind, +) => PullRequestProviderApi["unavailableRequirement"]; + +function unavailableRequirement( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string | undefined { + const requirement = requirementOf(error.provider); + if (requirement === undefined) return undefined; + return error.reason === "missing-tool" ? requirement.missing : requirement.unauthenticated; } -function toUnavailableError(error: PullRequestProviderError): PullRequestUnavailableError { - return new PullRequestUnavailableError({ - reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", - provider: error.provider, - cause: error, - }); +function providerDetail( + requirementOf: UnavailableRequirementOf, + error: PullRequestProviderError, +): string { + if (!isProviderUnusable(error)) return error.detail; + return unavailableRequirement(requirementOf, error) ?? error.detail; } -function toPullRequestError( - operation: string, -): (error: PullRequestProviderError) => PullRequestError { - return (error) => - isProviderUnusable(error) - ? toUnavailableError(error) - : new PullRequestOperationError({ - operation, - detail: error.detail, - ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), - cause: error, - }); +function toPullRequestErrorWith( + requirementOf: UnavailableRequirementOf, +): (operation: string) => (error: PullRequestProviderError) => PullRequestError { + return (operation) => (error) => { + if (isProviderUnusable(error)) { + const requirement = unavailableRequirement(requirementOf, error); + return new PullRequestUnavailableError({ + reason: error.reason === "missing-tool" ? "cli-missing" : "cli-unauthenticated", + provider: error.provider, + ...(requirement === undefined ? {} : { requirement }), + cause: error, + }); + } + return new PullRequestOperationError({ + operation, + detail: error.detail, + ...(error.reason === "not-found" ? { reason: "not-found" as const } : {}), + cause: error, + }); + }; } function withRateLimitBackoff( @@ -500,7 +501,13 @@ function withRateLimitBackoff( ...(api.mergeMessageRewrite === undefined ? {} : { mergeMessageRewrite: api.mergeMessageRewrite }), + ...(api.unavailableRequirement === undefined + ? {} + : { unavailableRequirement: api.unavailableRequirement }), ...(api.repositoryKey === undefined ? {} : { repositoryKey: api.repositoryKey }), + ...(api.readsPublicHostWithoutCheckout === undefined + ? {} + : { readsPublicHostWithoutCheckout: api.readsPublicHostWithoutCheckout }), getViewer: options?.viewerAllowsPause === true ? interactive("getViewer", api.getViewer) @@ -551,6 +558,9 @@ function withRateLimitBackoff( }), getViewerPermissions: interactive("getViewerPermissions", api.getViewerPermissions), getDiff: wrap("getDiff", api.getDiff), + ...(api.getDiffFilePaths === undefined + ? {} + : { getDiffFilePaths: wrap("getDiffFilePaths", api.getDiffFilePaths) }), ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), @@ -649,6 +659,21 @@ function rankFilterOptions
    ( }; } +const remoteHostKey = (recognized: SourceControlProviderInfo, remoteUrl: string) => + `${recognized.baseUrl}\n${isSshRemoteUrl(remoteUrl) ? "ssh" : "web"}`; + +/** An HTTP remote names its server's web host, port included. */ +function httpRemoteHost(remoteUrl: string): string | null { + try { + const remote = new URL(remoteUrl); + return remote.protocol === "http:" || remote.protocol === "https:" + ? remote.host.toLowerCase() + : null; + } catch { + return null; + } +} + // Capture before the provider read so a slow response keeps its original freshness through caches. const observeRead = Effect.fnUntraced(function* (read: Effect.Effect) { const observedAt = yield* Clock.currentTimeMillis; @@ -660,6 +685,9 @@ export const make = Effect.gen(function* () { const stateChanges = yield* PubSub.sliding(64); const pullRequestRefreshes = yield* SubscriptionRef.make(0); const registry = yield* PullRequestProviderRegistry.PullRequestProviderRegistry; + const requirementOf: UnavailableRequirementOf = (kind) => + registry.get(kind)?.unavailableRequirement; + const toPullRequestError = toPullRequestErrorWith(requirementOf); const serverSettings = yield* ServerSettings.ServerSettingsService; const projects = yield* ProjectService.ProjectService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -667,84 +695,133 @@ export const make = Effect.gen(function* () { const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; const filesViewedStore = yield* PullRequestFilesViewed.PullRequestFilesViewedRepository; const readCache = yield* PullRequestReadCache.PullRequestReadCache; + const clock = yield* Clock.Clock; - const refineUnknownProjectKinds = ( + /** + * Where each project's remote is served from, by the remote's recognised base URL: a remote no + * definition placed is identified by the hosting CLIs, and a host whose remotes may not name + * its web server says which one does. One project per base URL and transport answers for all + * of them. + */ + const resolveRemoteHosts = Effect.fn("PullRequestService.resolveRemoteHosts")(function* ( projects: ReadonlyArray, filter: Pick, - ) => { - type RefinementCandidate = { + ) { + type Candidate = { readonly project: OrchestrationProjectShell; - readonly provider: SourceControlProviderInfo; - readonly remoteName: string; - readonly remoteUrl: string; + readonly context: SourceControlProviderContext; }; - const refinements = new Map(); + const candidatesByKey = new Map(); for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; if ( - (identity?.provider !== "unknown" && - !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || - sourceControlRepositorySelector(project.repositoryIdentity) === null + !identity || + identity.provider === undefined || + sourceControlDefinitions.repositorySelector(identity) === null ) continue; - const host = pullRequestHostOf(identity, SourceControlProviderKind.make("unknown")); + if (identity.provider !== "unknown") { + const provider = yield* sourceControlProviders + .get(identity.provider) + .pipe(Effect.orElseSucceed(() => null)); + if (provider?.resolveRemoteWebHost === undefined) continue; + } + const host = sourceControlDefinitions.changeRequestHost(identity, "unknown"); if ( filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase() && - pullRequestHostOf(identity, SourceControlProviderKind.make("forgejo")) !== - filter.host.toLowerCase() && + httpRemoteHost(identity.locator.remoteUrl) !== filter.host.toLowerCase() && !isSshRemoteUrl(identity.locator.remoteUrl) ) { continue; } const { remoteName, remoteUrl } = identity.locator; - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider !== null) { - const candidates = refinements.get(provider.baseUrl); - const candidate = { project, provider, remoteName, remoteUrl }; - if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]); - else candidates.push(candidate); - } + const recognized = sourceControlDefinitions.recognizeRemote(remoteUrl); + if (recognized === null) continue; + const candidate = { + project, + context: { + provider: + identity.provider === "unknown" + ? recognized + : { ...recognized, kind: identity.provider }, + remoteName, + remoteUrl, + ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) + ? { requestedHost: filter.host } + : {}), + }, + }; + const key = remoteHostKey(recognized, remoteUrl); + const candidates = candidatesByKey.get(key); + if (candidates === undefined) candidatesByKey.set(key, [candidate]); + else candidates.push(candidate); } - return Effect.forEach( - refinements, - ([baseUrl, candidates]) => - Effect.firstSuccessOf( - candidates.map(({ project, provider, remoteName, remoteUrl }) => - Effect.suspend(() => - sourceControlProviders.resolveHandle({ - cwd: project.workspaceRoot, - context: { - provider: - provider.kind === "forgejo" - ? { ...provider, kind: SourceControlProviderKind.make("unknown") } - : provider, - remoteName, - remoteUrl, - ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) - ? { requestedHost: filter.host } - : {}), - }, - }), - ).pipe( - Effect.flatMap((handle) => { - const refined = handle.context?.provider; - return refined === undefined || refined.kind === "unknown" - ? Effect.fail(undefined) - : Effect.succeed(refined); - }), + const resolveContext = (input: { + readonly cwd: string; + readonly context: SourceControlProviderContext; + }) => sourceControlProviders.resolveHandle(input).pipe(Effect.map((handle) => handle.context)); + type RemoteHost = { readonly kind: SourceControlProviderKind; readonly host: string | null }; + const resolveCandidate = ({ project, context }: Candidate) => + (context.provider.kind === "unknown" + ? resolveContext({ cwd: project.workspaceRoot, context }).pipe( + Effect.map((resolved) => + resolved === null || resolved.provider.kind === "unknown" + ? null + : { context: resolved, discovered: true }, ), - ), - ).pipe( - Effect.map((provider) => [baseUrl, provider] as const), - Effect.orElseSucceed(() => [baseUrl, null] as const), + ) + : Effect.succeed({ context, discovered: false }) + ).pipe( + Effect.flatMap((found) => + found === null + ? Effect.succeed(null) + : sourceControlProviders.get(found.context.provider.kind).pipe( + Effect.flatMap( + (provider) => + provider.resolveRemoteWebHost?.({ + cwd: project.workspaceRoot, + context: found.context, + // A remote discovery just placed is not looked up a second time. + resolveContext: found.discovered + ? () => Effect.succeed(found.context) + : resolveContext, + }) ?? Effect.succeed(null), + ), + Effect.orElseSucceed(() => null), + Effect.map((host): RemoteHost => ({ kind: found.context.provider.kind, host })), + ), ), + Effect.orElseSucceed(() => null), + ); + + // An SSH remote whose web host one checkout could not name may be named by the next; a + // kind found without a host still stands if none can. + const resolveKey = Effect.fn("PullRequestService.resolveRemoteHost")(function* ( + candidates: ReadonlyArray, + ) { + let fallback: RemoteHost | null = null; + for (const candidate of candidates) { + const resolved = yield* resolveCandidate(candidate); + if (resolved === null) continue; + if (resolved.host !== null || !isSshRemoteUrl(candidate.context.remoteUrl)) { + return resolved; + } + fallback ??= resolved; + } + return fallback; + }); + + return yield* Effect.forEach( + candidatesByKey, + ([key, candidates]) => + resolveKey(candidates).pipe(Effect.map((resolved) => [key, resolved] as const)), { concurrency: REPOSITORY_CONCURRENCY }, ).pipe(Effect.map((resolved) => new Map(resolved))); - }; + }); const listWorkspaceProjects = ( filter: Pick, @@ -776,11 +853,11 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((projects) => - refineUnknownProjectKinds(projects, filter).pipe( - Effect.map((refinedProviders) => ({ refinedProviders, projects })), + resolveRemoteHosts(projects, filter).pipe( + Effect.map((remoteHosts) => ({ remoteHosts, projects })), ), ), - Effect.map(({ refinedProviders, projects }) => { + Effect.map(({ remoteHosts, projects }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -792,22 +869,18 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; - let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = sourceControlRepositorySelector(project.repositoryIdentity); + let kind = identity?.provider; + const repository = sourceControlDefinitions.repositorySelector( + project.repositoryIdentity, + ); if (!identity || kind === undefined || repository === null) continue; - let refinedProvider: SourceControlProviderInfo | null | undefined; - if ( - kind === "unknown" || - (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) - ) { - const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); - refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); - kind = refinedProvider?.kind ?? kind; - } - const host = - refinedProvider?.kind === "forgejo" - ? new URL(refinedProvider.baseUrl).host.toLowerCase() - : pullRequestHostOf(identity, kind); + const recognized = sourceControlDefinitions.recognizeRemote(identity.locator.remoteUrl); + const remote = + recognized === null + ? undefined + : remoteHosts.get(remoteHostKey(recognized, identity.locator.remoteUrl)); + kind = remote?.kind ?? kind; + const host = remote?.host ?? sourceControlDefinitions.changeRequestHost(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) { continue; } @@ -834,19 +907,20 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: + repositoryKey ?? + sourceControlDefinitions.repositoryKeyFromRemote(`https://${host}/${repository}`), repositoryKey: - repositoryKey === null ? null : canonicalRepositoryKey(repositoryKey.toLowerCase()), + repositoryKey === null + ? null + : sourceControlDefinitions.canonicalRepositoryKey(repositoryKey.toLowerCase()), }); } return { supported, unimplemented, viewerRoots }; }), ); - /** - * Azure derives its organization from the checkout, so it requires a matching - * repository. - */ + // Azure derives organization from the checkout; require the matching repository. const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { @@ -871,11 +945,13 @@ export const make = Effect.gen(function* () { }), ); } - const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); - // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare - // the complete repository identity before narrowing those checkouts by host. + const referenceKey = `${host}/${repository}`.toLowerCase(); + const repositoryKey = sourceControlDefinitions.canonicalRepositoryKey(referenceKey); + // A host that spells one repository several ways (Azure's SSH and legacy clone hosts) + // can differ from the browser URL's host. Compare the complete repository identity + // before narrowing those checkouts by host. return listWorkspaceProjects( - repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + sourceControlDefinitions.findByRepositoryKey(referenceKey) ? {} : { host }, ).pipe( Effect.flatMap(({ supported }) => { const onHost = supported.filter((candidate) => candidate.host === host); @@ -893,11 +969,9 @@ export const make = Effect.gen(function* () { onHost.find((candidate) => candidate.repositoryKey === null); if (route === undefined) { return Effect.gen(function* () { - const provider = ["github.com", "bitbucket.org", "codeberg.org"].includes(host) - ? detectSourceControlProviderFromRemoteUrl(`https://${host}/${repository}.git`) - : null; - const api = provider === null ? null : registry.get(provider.kind); - if (api !== null) { + const publicHost = sourceControlDefinitions.findByPublicHost(host); + const api = publicHost === undefined ? null : registry.get(publicHost.kind); + if (api?.readsPublicHostWithoutCheckout) { const selected = yield* projects.getShell(ref.projectId).pipe( Effect.mapError( (cause) => @@ -915,7 +989,9 @@ export const make = Effect.gen(function* () { api: withRateLimitBackoff(api, host, rateLimits), repository, host, - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlDefinitions.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), repositoryKey: null, }; } @@ -931,7 +1007,9 @@ export const make = Effect.gen(function* () { : { ...route, repository: normalizeSourceControlRepository(repository, route.api.kind), - remote: normalizeGitRemoteUrl(`https://${host}/${repository}`), + remote: sourceControlDefinitions.repositoryKeyFromRemote( + `https://${host}/${repository}`, + ), }, ); }), @@ -951,12 +1029,7 @@ export const make = Effect.gen(function* () { }; }); - /** - * Every write goes through it: the page hides what a viewer may not do, and a request that - * arrived without passing through the page — or after the access behind it was withdrawn — must - * not be handed to a provider on the client's word. Read freshly for that reason, rather than - * taken from whatever the detail said when the page loaded. - */ + // Access can change after page load; recheck every write. const viewerPermissionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -1034,8 +1107,7 @@ export const make = Effect.gen(function* () { }, { capacity: VIEWER_CACHE_CAPACITY, - // This short entry exists to keep simultaneous cold page reads on one in-flight lookup; - // failures remain retryable. + // Share cold-page lookups briefly; keep failures retryable. timeToLive: (exit) => Exit.isSuccess(exit) && exit.value.error === null ? Duration.seconds(1) : Duration.zero, }, @@ -1056,16 +1128,12 @@ export const make = Effect.gen(function* () { } const forHost = projects.filter((project) => project.host === host); const api = forHost[0]!.api; - // Every checkout on the host, not just the ones that survived de-duplication: one - // unreadable worktree would otherwise report the whole host as signed out. + // Include deduplicated checkouts too; one unreadable worktree must not mark the whole host signed out. const roots = viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot); const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); if (options?.allowPaused === true) return Cache.get(viewerFlights, key); - // The pause is checked here rather than inside the lookup, so that it holds back the - // callers nobody is waiting on without splitting the flight they share with a press. - // A failed lookup is held nowhere, so letting a background read through would spawn - // this host's CLI on every refresh for as long as the pause lasted, and re-extend it. + // Check pauses outside the shared lookup so background reads cannot spawn repeated failing CLIs and extend the pause. return rateLimits.check({ provider: api.kind, host }).pipe( Effect.flatMap(() => Cache.get(viewerFlights, key)), Effect.catch((error) => @@ -1088,10 +1156,7 @@ export const make = Effect.gen(function* () { { concurrency: REPOSITORY_CONCURRENCY }, ); - /** - * `checks` is absent because no listed row carries its check state: that one filter is - * the host's alone, and a row nobody narrowed stays rather than being guessed at. - */ + // Only the host can filter checks; listed rows have no check state. const matchesRowFilters = ( item: ProviderChangeRequest, filters: PullRequestListFilters | undefined, @@ -1156,13 +1221,6 @@ export const make = Effect.gen(function* () { }; }; - // A repository that has appeared in a host search is known to be indexed there. Empty - // authored/reviewing searches for that same repository are therefore real empty answers, not - // a reason to issue the two-command per-repository fallback again. - const searchVisibleAt = new Map(); - const searchVisibilityKey = (host: string, repository: string) => - `${host}\n${repository.trim().toLowerCase()}`; - const listUncached: PullRequestService["Service"]["list"] = Effect.fn( "PullRequestService.listUncached", )(function* (input) { @@ -1191,7 +1249,7 @@ export const make = Effect.gen(function* () { actions: capabilities?.actions ?? [], projectCount: projectCounts.get(result.host) ?? 1, configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), + detail: result.error === null ? null : providerDetail(requirementOf, result.error), }; }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ @@ -1372,75 +1430,53 @@ export const make = Effect.gen(function* () { : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), }).pipe( observeRead, - Effect.flatMap(({ value: page, observedAt }) => - Effect.flatMap(Clock.currentTimeMillis, (now) => { - const rows = new Map>(); - for (const [key, visibleAt] of searchVisibleAt) { - if (now - visibleAt > Duration.toMillis(SEARCH_VISIBILITY_TTL)) { - searchVisibleAt.delete(key); - } - } - for (const item of page.items) { - const key = item.repository.trim().toLowerCase(); - const held = rows.get(key); - if (held === undefined) rows.set(key, [item]); - else held.push(item); - searchVisibleAt.set(searchVisibilityKey(first.host, item.repository), now); - } - const boundary = page.items.reduce( - (oldest, item) => - oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, - null, - ); - return Effect.forEach( - chunk, - (project): Effect.Effect => { - const fetched = rows.get(project.repository.trim().toLowerCase()) ?? []; - // GitHub does not index every repository for search — a renamed one answers for - // its old name with silence rather than with an error — so a repository the - // search said nothing at all about is read on its own, once, before it is - // believed. Only on its first slice: after that it has a boundary to carry on - // from, and silence past one means the rows are older rather than absent. - const lastVisible = searchVisibleAt.get( - searchVisibilityKey(project.host, project.repository), - ); - const searchIsKnownVisible = - !page.truncated && - lastVisible !== undefined && - now - lastVisible <= Duration.toMillis(SEARCH_VISIBILITY_TTL); - if ( - fetched.length === 0 && - cursorOf(project) === undefined && - !searchIsKnownVisible - ) { - return readRepository(project); - } - const cursorHere = cursorOf(project); - const items = - cursorHere === undefined - ? fetched - : fetched.filter( - (item) => - item.updatedAt !== cursorHere.updatedBefore || - !cursorHere.seenAt.includes(item.number), - ); - return Effect.succeed({ - key: project.cursorKey, - entries: items - .filter((item) => matchesRowFilters(item, input.filters, viewer)) - .map((item) => toEntry({ project, item, viewer, observedAt })), - errors: [], - truncated: page.truncated, - nextCursor: - page.truncated && boundary !== null - ? listCursorAt(cursorHere, boundary, fetched, items.length) - : null, - }); - }, - { concurrency: REPOSITORY_CONCURRENCY }, - ); - }), - ), + Effect.flatMap(({ value: page, observedAt }) => { + const rows = new Map>(); + for (const item of page.items) { + const key = item.repository.trim().toLowerCase(); + const held = rows.get(key); + if (held === undefined) rows.set(key, [item]); + else held.push(item); + } + const unresolved = new Set( + (page.unresolved ?? []).map((repository) => repository.trim().toLowerCase()), + ); + const boundary = page.items.reduce( + (oldest, item) => + oldest === null || item.updatedAt < oldest ? item.updatedAt : oldest, + null, + ); + return Effect.forEach( + chunk, + (project): Effect.Effect => { + const key = project.repository.trim().toLowerCase(); + const fetched = rows.get(key) ?? []; + if (fetched.length === 0 && unresolved.has(key)) return readRepository(project); + const cursorHere = cursorOf(project); + const items = + cursorHere === undefined + ? fetched + : fetched.filter( + (item) => + item.updatedAt !== cursorHere.updatedBefore || + !cursorHere.seenAt.includes(item.number), + ); + return Effect.succeed({ + key: project.cursorKey, + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer, observedAt })), + errors: [], + truncated: page.truncated, + nextCursor: + page.truncated && boundary !== null + ? listCursorAt(cursorHere, boundary, fetched, items.length) + : null, + }); + }, + { concurrency: REPOSITORY_CONCURRENCY }, + ); + }), Effect.catch(separately), ); }; @@ -1513,7 +1549,7 @@ export const make = Effect.gen(function* () { host, }) .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); - return { ...identity, host, provider: "github" as const }; + return { ...identity, host, provider: api.kind }; }); const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = Effect.fn( @@ -1523,7 +1559,7 @@ export const make = Effect.gen(function* () { const rejected = () => new PullRequestOperationError({ operation: "routeIdentity", - detail: "The GitHub account could not be verified before starting the operation.", + detail: "The signed-in account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); const api = registry.get(project.api.kind); @@ -1868,11 +1904,7 @@ export const make = Effect.gen(function* () { const scope = yield* Effect.scope; const runFork = Effect.runForkWith(context); - /** - * A lookup that failed is refused rather than answered as the unnamed reader: a momentarily - * signed-out CLI would otherwise hide every tick this reader has made and file the next press - * under rows that are orphaned once it recovers. - */ + // Refuse failed viewer lookups; unnamed fallbacks hide marks and orphan subsequent writes. const requiredViewerOf = ( project: SupportedProject, operation: string, @@ -1931,9 +1963,7 @@ export const make = Effect.gen(function* () { }), ); } - // A strategy the host does not offer must be refused rather than passed on: every - // provider maps an unrecognised method to its own default, so asking Azure DevOps to - // rebase would quietly merge instead of failing. + // Refuse unsupported strategies; providers silently map unrecognized methods to their default. if ( input.mergeMethod !== undefined && !project.api.capabilities.mergeMethods.includes(input.mergeMethod) @@ -2097,12 +2127,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Neither is a question a permission read answers: every host lets the person who wrote - * something rewrite it whatever access they have otherwise, and none of them reports that as a - * permission — so a check here could only guess, and a wrong guess takes the control away from - * the one person certain to be allowed. - */ + // Hosts let authors edit without reporting that permission; checking it would wrongly deny access. const update: PullRequestService["Service"]["update"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2297,10 +2322,7 @@ export const make = Effect.gen(function* () { }), ); - /** - * Every host with reactions takes one from whoever can read the change request, so there - * is no access left to check that reading it has not already settled. - */ + // Hosts permit reactions from any reader; reading the PR already checks access. const setReaction: PullRequestService["Service"]["setReaction"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -2617,6 +2639,11 @@ export const make = Effect.gen(function* () { if (oldest !== undefined) held.delete(oldest); } held.set(key, { at, value }); + for (const evicted of diffCacheEvictions( + Array.from(held, ([key, snapshot]) => [key, snapshot.value] as const), + )) { + held.delete(evicted); + } armDiffSweep(); }); const serve = (key: string, read: Effect.Effect) => { @@ -2624,8 +2651,7 @@ export const make = Effect.gen(function* () { return Effect.flatMap(Clock.currentTimeMillis, (now) => { const snapshot = held.get(key); if (snapshot === undefined || now - snapshot.at > staleMs) return recorded; - // Run as its own fiber rather than a child: the caller is answered and gone before the - // refresh lands. + // Use an independent fiber; refresh outlives the answered caller. return revalidate(recorded).pipe(Effect.as(snapshot.value)); }); }; @@ -2692,8 +2718,7 @@ export const make = Effect.gen(function* () { const lastGoodSummary = makeLastGoodRead(DETAIL_CACHE_CAPACITY); const lastGoodDetail = makeLastGoodRead(DETAIL_CACHE_CAPACITY); - // The counter is shared and monotonic so a scope re-entering `refEpochs` after eviction can - // never mint a key an old entry still has. + // A shared monotonic counter prevents evicted scopes reusing epochs held by old entries. let epochCounter = 0; let listingsEpoch = 0; const refEpochs = new Map(); @@ -2724,8 +2749,6 @@ export const make = Effect.gen(function* () { } mergedEpochs.set(scope, projectEpochs.get(ref.projectId) ?? projectEpochFloor); }; - // Keys carry the reference back out of the cache loader, so the slot layout is shared with - // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: CredentialRef) => JSON.stringify([ refEpoch(ref), @@ -2938,8 +2961,9 @@ export const make = Effect.gen(function* () { ]); // A replacement reader can join a lookup still finishing its previous reader's cancellation. return Cache.get(listCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listCache, key), ), ); }; @@ -2968,7 +2992,20 @@ export const make = Effect.gen(function* () { }, { capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? CHECKS_CACHE_TTL : Duration.zero), + // Expire from the read's start so checks seeded from a slow detail read keep their age. + timeToLive: (exit) => + !Exit.isSuccess(exit) + ? Duration.zero + : exit.value === null + ? CHECKS_CACHE_TTL + : Duration.millis( + Math.max( + 0, + exit.value.observedAt + + Duration.toMillis(CHECKS_CACHE_TTL) - + clock.currentTimeMillisUnsafe(), + ), + ), }, ); @@ -3002,6 +3039,18 @@ export const make = Effect.gen(function* () { return detailUncached(refOfCacheKey(key)).pipe( Effect.tap( Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { + // GitHub detail includes the same checks; the checks TTL counts from their observation. + if ( + value.provider === "github" && + value.observedAt !== undefined && + !(yield* Cache.has(checksCache, key)) + ) { + yield* Cache.set(checksCache, key, { + state: value.state, + checks: value.checks, + observedAt: value.observedAt, + }); + } recordStats( statsKey, { @@ -3051,8 +3100,7 @@ export const make = Effect.gen(function* () { }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => supersedesReading(lastGoodSummary.peek(key), next); - // The newest state a detail read found per pull request, whatever cache epoch or credential - // it read under. Summary reads stay out: they would hide a change from the detail read after. + // Keep newest detail states across epochs/credentials; summary reads would mask subsequent detail changes. const detailStates = new Map(); const noteDetailReading = (ref: PullRequestRef, next: PullRequestSummary) => Effect.suspend(() => { @@ -3079,8 +3127,7 @@ export const make = Effect.gen(function* () { }) : Effect.void; }); - // The last state each routed read reported, kept apart from `detailStates`: an unverified report - // must never outrank a reading this environment made itself. + // Keep routed reports separate; unverified reports must not outrank this environment's reads. const reportedStates = new Map< string, { @@ -3123,9 +3170,7 @@ export const make = Effect.gen(function* () { ); const detail: PullRequestService["Service"]["detail"] = (input) => { const key = refCacheKey(input); - // Skip the write when that read is older than a later strict summary — - // display reuse would otherwise keep the regression and never ask the host - // again. + // Skip reads older than strict summaries; caching them would preserve regressions without asking the host again. const read = Cache.get(detailCache, key).pipe( Effect.tap((value) => { if (value.state === "merged") noteMerged(input); @@ -3214,24 +3259,30 @@ export const make = Effect.gen(function* () { ...refOfCacheKey(reference), ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), - } as PullRequestDiffInput); + } as PullRequestDiffInput).pipe( + observeRead, + Effect.tap(({ value, observedAt }) => { + // Keep the loader's epoch and read time if a refresh occurs during the provider read. + diffFiles.record(JSON.stringify([reference, cursor, commit]), value, observedAt); + return Effect.void; + }), + ); }, { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; const commit = (JSON.parse(key) as ReadonlyArray)[2]; - return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; + const ttl = Duration.toMillis(commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL); + return Duration.millis( + Math.max(0, exit.value.observedAt + ttl - clock.currentTimeMillisUnsafe()), + ); }, }, ); - /** - * Both diff holders drop an expired value only when its own key is read again, and a diff's key - * moves with every push, so a closed page's patches would stay held until the server restarted. - */ + // Diffs expire only when their keys are reread; push-dependent keys leave closed pages' patches retained. let diffSweepPending = false; - // Forked from the service's own context, in one step with the flag: a caller's interruption - // cannot strand the flag, and its request is not kept alive by the wait. + // Fork in service context atomically with the flag to avoid stranded flags and retained caller requests. const armDiffSweep = () => { if (diffSweepPending) return; diffSweepPending = true; @@ -3251,7 +3302,7 @@ export const make = Effect.gen(function* () { if (held > 0) armDiffSweep(); }), ); - const diff: PullRequestService["Service"]["diff"] = (input) => { + const readDiff = (input: PullRequestDiffInput) => { const key = JSON.stringify([ refCacheKey(input), input.cursor ?? null, @@ -3260,22 +3311,24 @@ export const make = Effect.gen(function* () { ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) : null, ]); - const read = Cache.get(diffCache, key).pipe( - Effect.tap((value) => - canCacheDiff(value) - ? Effect.void - : Cache.getSuccess(diffCache, key).pipe( - Effect.flatMap((current) => - Option.isSome(current) && current.value === value - ? Cache.invalidate(diffCache, key) - : Effect.void, - ), - Effect.uninterruptible, - ), + const evictDiffs = Cache.entries(diffCache).pipe( + Effect.flatMap((entries) => + Effect.forEach( + diffCacheEvictions(Array.from(entries, ([key, read]) => [key, read.value] as const)), + (evicted) => Cache.invalidate(diffCache, evicted), + ), ), + Effect.uninterruptible, + ); + const read = Cache.get(diffCache, key).pipe( + Effect.ensuring(evictDiffs), Effect.ensuring(Effect.sync(armDiffSweep)), ); - return staleDiff.serve(key, read); + return { key, read }; + }; + const diff: PullRequestService["Service"]["diff"] = (input) => { + const { key, read } = readDiff(input); + return staleDiff.serve(key, read.pipe(Effect.map((read) => read.value))); }; const filesViewedCache = yield* Cache.makeWith( @@ -3288,8 +3341,7 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? FILES_VIEWED_CACHE_TTL : Duration.zero), }, ); - // Canonicalised before it is keyed: both epochs are bumped against the remote's own spelling, - // so a reference keyed as the client spelled it would never see a refresh or a press. + // Key canonical refs to match epochs bumped with the remote's spelling. const filesViewed: PullRequestService["Service"]["filesViewed"] = (input) => canonicalRef(input).pipe( Effect.flatMap((ref) => @@ -3341,8 +3393,9 @@ export const make = Effect.gen(function* () { if (missing.size === 0) return { stats: held }; const key = statsBatchKey(missing.values()); const { result, at } = yield* Cache.get(listStatsCache, key).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) ? Cache.get(listStatsCache, key) : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => Cause.hasInterruptsOnly(cause), + (_cause) => Cache.get(listStatsCache, key), ), ); for (const [key, ref] of missing) { @@ -3472,10 +3525,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * The file names in each diff slice, keyed as the slice is. Names stay small when a patch is too - * large to cache, so expanding a file in a large change request does not fetch its diff again. - */ + // Cache small filename lists even when patches exceed limits, avoiding diff refetches on file expansion. const diffFiles = (() => { const held = new Map< string, @@ -3489,19 +3539,10 @@ export const make = Effect.gen(function* () { const fileKey = (oldPath: string, newPath: string) => JSON.stringify([oldPath, newPath]); const sliceKey = (input: PullRequestDiffInput) => JSON.stringify([refCacheKey(input), input.cursor ?? null, input.commit ?? null]); - const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { - const key = sliceKey(input); - const now = yield* Clock.currentTimeMillis; - const cached = held.get(key); - if (cached !== undefined && now - cached.at <= ttlMs) return cached; - const slice = yield* diff(input); + const recordPaths = (key: string, slice: ProviderDiffFilePaths, at: number) => { const entry = { - at: now, - files: new Set( - parseDiffFilePaths(slice.patch).map((file) => - fileKey(file.oldPath ?? file.newPath ?? "", file.newPath ?? file.oldPath ?? ""), - ), - ), + at, + files: new Set(slice.files.map((file) => fileKey(file.oldPath, file.newPath))), nextCursor: slice.nextCursor, }; held.delete(key); @@ -3511,13 +3552,43 @@ export const make = Effect.gen(function* () { } held.set(key, entry); return entry; + }; + const record = (key: string, slice: PullRequestDiffResult, at: number) => + recordPaths( + key, + { + files: parseDiffFilePaths(slice.patch).map((file) => ({ + oldPath: file.oldPath ?? file.newPath ?? "", + newPath: file.newPath ?? file.oldPath ?? "", + })), + nextCursor: slice.nextCursor, + }, + at, + ); + const read = Effect.fn("PullRequestService.read")(function* (input: PullRequestDiffInput) { + const key = sliceKey(input); + const now = yield* Clock.currentTimeMillis; + const cached = held.get(key); + if (cached !== undefined && now - cached.at <= ttlMs) return cached; + const project = yield* requireProject(input); + if (project.api.capabilities.diff && project.api.getDiffFilePaths) { + const slice = yield* project.api + .getDiffFilePaths({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diffFileContents"))); + return recordPaths(key, slice, now); + } + const { value, observedAt } = yield* readDiff(input).read; + return record(key, value, observedAt); }); - return { read: credentialCached(read), fileKey }; + return { read: credentialCached(read), record, fileKey }; })(); - /** - * Whether the change request, or the one commit asked for, changes this file under these names. - * Walks the same slices the reader was shown, reading only their file names once held. - */ const changesFile = (input: PullRequestDiffFileContentsInput) => { const wanted = diffFiles.fileKey(input.oldPath, input.newPath); const walk = (cursor: string | undefined): Effect.Effect => @@ -3538,9 +3609,7 @@ export const make = Effect.gen(function* () { ); return walk(undefined); }; - // Only the files the change request itself changes: these are read with the project's own - // credential, and a reader who may look at the change request may not thereby read the rest of - // the repository. + // PR read access does not grant arbitrary repository reads with the project credential; restrict to changed files. const diffFileContents: PullRequestService["Service"]["diffFileContents"] = (input) => changesFile(input).pipe( Effect.flatMap((changed) => @@ -3562,10 +3631,13 @@ export const make = Effect.gen(function* () { withRoutingCredential, list, filterOptions, - listStats: (input) => - Effect.forEach(input.refs, (ref) => canonicalRef(ref).pipe(Effect.option)).pipe( - Effect.flatMap((refs) => listStats({ ...input, refs: refs.flatMap(Option.toArray) })), - ), + // The local operation owns the span; this wrapper only canonicalizes refs. + listStats: Effect.fnUntraced(function* (input) { + const refs = yield* Effect.forEach(input.refs, (ref) => + canonicalRef(ref).pipe(Effect.option), + ); + return yield* listStats({ ...input, refs: refs.flatMap(Option.toArray) }); + }), summary: credentialCached(summary), stack: credentialCached(stack), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( diff --git a/apps/server/src/pullRequest/http.ts b/apps/server/src/pullRequest/http.ts index 746e29a506a65..0e9f353900eb3 100644 --- a/apps/server/src/pullRequest/http.ts +++ b/apps/server/src/pullRequest/http.ts @@ -6,7 +6,7 @@ import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/htt import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as PullRequestService from "./PullRequestService.ts"; -/** The patch is often the largest PR payload and benefits from HTTP compression and flow control. */ +// Large patches benefit from HTTP compression and flow control. export const layer = HttpApiBuilder.group( EnvironmentHttpApi, "pullRequests", diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index fa51017ebcd55..c7a2b01946a2b 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,7 +1,7 @@ import { normalizeThreadPullRequestKey, threadPullRequestKeysEqual, -} from "@t3tools/shared/threadPullRequests"; +} from "../sourceControl/threadPullRequests.ts"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts index 2b21e20d15fcd..48db0795edbab 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -1,19 +1,17 @@ -import { ProjectId } from "@t3tools/contracts"; +import { ProjectId, SourceControlProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; describe("pullRequestSyncKey", () => { const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 }; - it.each([ - "dev.azure.com/org/project/_git/web", - "ssh.dev.azure.com/v3/org/project/web", - "vs-ssh.visualstudio.com/v3/org/project/web", - "org.visualstudio.com/defaultcollection/project/_git/web", - ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { + // Every spelling of the key reaches dev.azure.com through the definition's canonical key, + // which the Azure DevOps package tests. + it("resolves hostless and checkout-host references named by the bare repository", () => { + const canonicalKey = "ssh.dev.azure.com/v3/org/project/web"; const identity = { canonicalKey, - provider: "azure-devops", + provider: SourceControlProviderKind.make("azure-devops"), name: "web", displayName: canonicalKey.split("/").slice(1).join("/"), locator: { diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts index 3b5ecc380d031..1e74563fd6104 100644 --- a/apps/server/src/pullRequest/pullRequestSyncKey.ts +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -1,43 +1,39 @@ -import { - pullRequestHostOf, - type PullRequestRef, - type RepositoryIdentity, - SourceControlProviderKind, - type ThreadPullRequestKey, -} from "@t3tools/contracts"; -import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import type { PullRequestRef, RepositoryIdentity, ThreadPullRequestKey } from "@t3tools/contracts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; +import { normalizeThreadPullRequestKey } from "../sourceControl/threadPullRequests.ts"; export function pullRequestSyncKey( reference: PullRequestRef, identity?: RepositoryIdentity | null, ): ThreadPullRequestKey | null { - if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + const host = identity + ? sourceControlDefinitions.changeRequestHost(identity, identity.provider) + : undefined; + // A host whose tools select a repository by something other than its path (Azure DevOps takes + // the bare name) can only key such a reference through the project's own repository. + const definition = identity?.provider + ? sourceControlDefinitions.find(identity.provider) + : undefined; + if (identity && definition?.repositorySelector && !reference.repository.includes("/")) { if ( reference.repository.toLowerCase() !== - sourceControlRepositorySelector(identity)?.toLowerCase() || - (reference.host !== undefined && - reference.host.toLowerCase() !== - pullRequestHostOf(identity, SourceControlProviderKind.make("azure-devops"))) + definition.repositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && reference.host.toLowerCase() !== host) ) return null; - const [host, ...repository] = identity.canonicalKey.split("/"); - if (!host || repository.length === 0) return null; + const [projectHost, ...repository] = identity.canonicalKey.split("/"); + if (!projectHost || repository.length === 0) return null; return normalizeThreadPullRequestKey({ - host, + host: projectHost, repository: repository.join("/"), number: reference.number, }); } - const host = - reference.host ?? - (identity - ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) - : undefined); - return host === undefined + const resolvedHost = reference.host ?? host; + return resolvedHost === undefined ? null : normalizeThreadPullRequestKey({ - host, + host: resolvedHost, repository: reference.repository, number: reference.number, }); diff --git a/apps/server/src/pullRequest/pullRequestViewedFiles.ts b/apps/server/src/pullRequest/pullRequestViewedFiles.ts index fc2913e3efd43..596808eb46a07 100644 --- a/apps/server/src/pullRequest/pullRequestViewedFiles.ts +++ b/apps/server/src/pullRequest/pullRequestViewedFiles.ts @@ -46,10 +46,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const heldFileRevisions = new Map(); const refreshingFileRevisions = new Set(); - /** - * Spelled from the project rather than the reference, since a reference arrives however - * the client spelled it while the epoch is bumped against the remote's own spelling. - */ + // Epochs use remote spelling; canonicalize client refs before keying. const fileRevisionsKey = (project: SupportedProject, ref: PullRequestRef) => [ refEpoch({ ...ref, host: project.host, repository: project.repository }), @@ -92,8 +89,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { const oldest = heldFileRevisions.keys().next().value; if (oldest !== undefined) heldFileRevisions.delete(oldest); } - // The entry is only as fresh as its oldest revision: stamping it with `now` on a partial - // answer would let an old revision ride past the point it should have been re-read. + // Partial answers retain the oldest timestamp so carried revisions cannot outlive their freshness. const stamped = [...revisions.keys()].every((path) => answer.revisions.has(path)) ? at : (carried?.at ?? at); @@ -110,11 +106,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return paths.every((path) => held.asked.has(path)) ? held : null; }; - /** - * `held` answers from a stale value and refetches off the critical path, since a badge a - * moment behind beats a page that won't paint until the host answers; `fresh` is for the - * press itself, which must not store a revision the head already moved off. - */ + // Held reads refresh off the critical path; presses need fresh revisions to avoid saving obsolete heads. const fileRevisionsOf = ( project: SupportedProject, ref: PullRequestRef, @@ -124,8 +116,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { ): Effect.Effect | null, PullRequestError> => { const read = project.api.getFileRevisions; if (read === undefined) return Effect.succeed(null); - // Suspended, so a held answer costs the host nothing: a provider is free to do its work as - // the request is built rather than as the effect is run. + // Suspend request construction too; providers may start work while building requests. const fetch = Effect.suspend(() => { const key = fileRevisionsKey(project, ref); return read({ @@ -147,8 +138,7 @@ const makeFileRevisions = (dependencies: FileRevisionsDependencies) => { return Effect.succeed(held.revisions); if (freshness === "fresh") return fetch; if (refreshingFileRevisions.has(key)) return Effect.succeed(held.revisions); - // Its own fiber rather than a child: the caller has been answered and is gone before this - // lands. + // Refresh independently; it outlives the answered caller. return Effect.sync(() => { refreshingFileRevisions.add(key); runFork( @@ -174,10 +164,7 @@ export interface Dependencies extends FileRevisionsDependencies { ) => Effect.Effect; } -// A plain factory rather than a `Context.Service` (against the preference in -// `.repos/effect-smol/LLMS.md`): the held revisions, refresh set, and write gates are only correct -// at one instance per service, and a layer provided at two points would give two of each behind -// one epoch counter. +// Use one factory instance per service; multiple layers would duplicate revision state and gates behind one epoch. export const make = (dependencies: Dependencies) => { const { filesViewedStore, requireProject, requiredViewerOf, toPullRequestError } = dependencies; const { fileRevisionsOf } = makeFileRevisions(dependencies); @@ -237,10 +224,7 @@ export const make = (dependencies: Dependencies) => { }, ); - /** - * A tick's host round trip is slower than an untick's, so unordered presses could finish - * out of order and leave a stale tick standing over a later untick. - */ + // Serialize presses so slower tick lookups cannot overwrite later unticks. const filesViewedGates = new Map< string, { readonly gate: Semaphore.Semaphore; pending: number } @@ -251,9 +235,7 @@ export const make = (dependencies: Dependencies) => { number: number, write: Effect.Effect, ) => - // Suspended rather than generated, so finding the gate, putting it in and taking a place in - // its queue are one step: yielding for `Semaphore.make` between the lookup and the insert - // lets two presses each make a gate of their own and neither wait on the other. + // Atomically find, register and queue on gates; yielding during creation lets concurrent presses use separate gates. Effect.suspend(() => { const key = `${project.project.id} ${project.remote} ${number}`; const held = filesViewedGates.get(key); @@ -294,9 +276,7 @@ export const make = (dependencies: Dependencies) => { yield* filesViewedStore .set({ ...filesViewedScope(project, input.number, viewer), - // A path left out of the answer stores with no baseline, not the empty revision, since - // the empty revision is itself an answer and would misreport the file once it turns - // out to have a version after all. + // Missing paths have null baselines; empty revision means confirmed deletion. files: input.files.map((file) => ({ path: file.path, revision: revisions?.get(file.path) ?? null, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d1ad9e7a58a64..e101b7475cdf8 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -188,6 +188,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; }), listActiveSubagentThreadIds: unused, + listOlderSettledThreads: unused, readShellSnapshot: unused, ensureLegacyTranscript: unused, dispatch: unused, @@ -215,6 +216,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleThread: unused, interruptThread: unused, stopWorkflow: () => Effect.die("unused stopWorkflow"), + awaitBackgroundTasks: unused, stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, @@ -268,18 +270,16 @@ const makeTestRelay = Effect.fnUntraced(function* ( getShell: unused, listShells: unused, getById: () => - Effect.succeed( - Option.some({ - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/workspace", - defaultModelSelection: null, - scripts: [], - createdAt: NOW, - updatedAt: NOW, - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + }), }), Effect.provideService(FetchHttpClient.Fetch, fetch), Effect.provide(NodeCrypto.layer), @@ -391,6 +391,8 @@ describe("AgentAwarenessRelay", () => { const terminal = shell({ status }); yield* Ref.set(currentShell, terminal); yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.equal(publications[1]?.state?.phase, status); const now = yield* DateTime.now; @@ -418,14 +420,121 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); - it.effect("keeps settled live work until its monitor finishes", () => + it.effect("holds a completion through a continuation gap, then publishes it once", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell()); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.length, 1); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }), + ); + + it.effect("still publishes a completion when the thread settles during the hold", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", null], + ); + }), + ); + + it.effect("drops a held completion when a queued continuation hides the thread", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ status: "queued" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.notInclude( + publications.map((publication) => publication.state?.phase ?? null), + "completed", + ); + }), + ); + + it.effect("retries a failed completion even after the thread settles", () => + Effect.gen(function* () { + const { relay, currentShell, publications } = yield* makeTestRelay({ + respond: (attempt) => + attempt === 2 + ? new Response("unavailable", { status: 503 }) + : Response.json({ ok: true, deliveries: [] }), + }); + yield* relay.publishThread(THREAD_ID); + const completed = shell({ status: "completed" }); + yield* Ref.set(currentShell, completed); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + yield* Ref.set(currentShell, { + ...completed, + settledOverride: "settled", + settledAt: yield* DateTime.now, + lastVisitedAt: null, + }); + yield* TestClock.adjust("1 second"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", "completed", "completed"], + ); + + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.at(-1)?.state ?? null, null); + }), + ); + + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); const held = shell({ status: "completed", settledOverride: "settled", - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }); yield* Ref.set(currentShell, held); yield* relay.publishThread(THREAD_ID); @@ -594,6 +703,8 @@ describe("AgentAwarenessRelay", () => { yield* deliver( itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), ); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; assert.deepEqual( publications.map((publication) => publication.state?.phase), ["running", "completed"], diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index c6a61817f69b8..da72320af27ae 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -104,6 +104,7 @@ function shouldPublishAgentAwarenessEvent( return true; // Unsettling must not re-alert a dismissed result. case "thread.unsettled": + case "thread.guest-share-updated": case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": @@ -147,8 +148,7 @@ export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* ( const enqueue = (threadId: ThreadId) => Effect.suspend(() => { if (queued.has(threadId)) return Effect.void; - // Removing the ID when processing starts allows one new queue entry - // for updates received while the current snapshot is being published. + // Remove queued IDs when processing starts so publication can queue one later update. queued.add(threadId); return worker.enqueue(threadId); }).pipe(Effect.uninterruptible); @@ -359,7 +359,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -409,10 +409,10 @@ export const make = Effect.gen(function* () { let cachedRelayClient: Effect.Success> | undefined; let publishedRelayConfig: NonNullable> | undefined; - // The confirming publish is re-enqueued through the same drainable worker as - // every other publish, so a confirmed tombstone can never race an in-flight - // live update; a recovered state clears the deadline. + // Confirm through the same worker so tombstones cannot race live updates; recovery clears the deadline. const publishConfirmDeadlines = new Map(); + // Threads whose confirmation holds a completion, so a thread that settles during the hold still alerts. + const heldCompletions = new Set(); let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect = () => Effect.void; const publishRetries = new Map< ThreadId, @@ -431,6 +431,7 @@ export const make = Effect.gen(function* () { publishedRelayConfig = undefined; cachedRelayClient = undefined; publishConfirmDeadlines.clear(); + heldCompletions.clear(); yield* Ref.set(publishedStateByThreadRef, new Map()); }); @@ -511,17 +512,13 @@ export const make = Effect.gen(function* () { }); }); - // Per-thread shell read: this publish runs for every activity-relevant - // domain event, so materializing the full shell here would make the cost - // of one thread's activity proportional to how many threads exist. + // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( threadShell?.lineage.relationshipToParent === "subagent" && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) ) { - // Subagents never project activity, so the relay holds no row to clear. - // Their events would otherwise publish a tombstone each, and every - // publish re-delivers the user's aggregate. + // Subagents have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; } const thread = @@ -546,11 +543,9 @@ export const make = Effect.gen(function* () { if (Option.isNone(thread) || !terminalWorkSinceStart(thread.value, startedAt)) return; } if (publishedStateByThread.get(threadId) === publishIdentity) { - // The projection is back at (or never left) the last published state, so - // any pending deferred confirmation is moot. Leaving the deadline in - // place would let a much later transient null find it already expired - // and publish a tombstone immediately, skipping the deferral window. + // Clear moot deadlines so later transient nulls cannot skip their deferral window. publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, @@ -559,16 +554,15 @@ export const make = Effect.gen(function* () { return; } - // - null (tombstone) while the previous published state was live: deletes - // the thread from every armed card mid-conversation. - // - completed as the thread's FIRST published state: sessions boot at - // "ready" before their first turn, which projects as completed for an - // instant and sends a spurious Done notification at thread birth. + // Confirm tombstones over live state to avoid removing active cards. + // Confirm completion: a run can end moments before its continuation starts, and the relay + // alerts on every move into completed. const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + snapshot.state?.phase === "completed"; if (requiresConfirmation) { + if (snapshot.state?.phase === "completed") heldCompletions.add(threadId); const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); if (deadline === undefined) { @@ -593,8 +587,38 @@ export const make = Effect.gen(function* () { statePhase: snapshot.state?.phase ?? null, shell: describeThreadShellForAwareness(thread), }); + // Settled during the hold, not resumed: alert the current completion, then clear the card + // on a new window. Only settlement may hide it; queued or silent runs also project to null. + const unsettled = + snapshot.state === null && + heldCompletions.has(threadId) && + Option.isSome(thread) && + thread.value.settledOverride === "settled" + ? resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some({ ...thread.value, settledOverride: null }), + project, + }) + : null; + if (unsettled?.state?.phase === "completed") { + const completion = unsettled.state; + yield* publishState({ + projectId: unsettled.projectId, + state: completion, + reason: unsettled.reason, + }).pipe(withRelayClientTracing); + heldCompletions.delete(threadId); + publishConfirmDeadlines.set(threadId, nowMs + 5_000); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => + new Map(publishedStates).set(threadId, agentAwarenessPublishIdentity(completion)), + ); + yield* schedulePublishConfirm(threadId); + return; + } } else { publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); } if (snapshot.reason === "thread-not-found") { @@ -616,6 +640,7 @@ export const make = Effect.gen(function* () { reason: snapshot.reason, }).pipe(withRelayClientTracing); publishConfirmDeadlines.delete(threadId); + heldCompletions.delete(threadId); yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { const nextPublishedStates = new Map(publishedStates); if (snapshot.state === null) { @@ -657,8 +682,7 @@ export const make = Effect.gen(function* () { const attempts = publishRetries.get(threadId)?.attempts ?? 0; const delayMs = RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS[attempts]; if (delayMs === undefined) { - // Keep the exhausted budget until fresh activity or a successful publish - // clears it; an old confirmation timer must not start another retry series. + // Retain exhausted budgets until activity or success resets them; old timers must not restart retries. yield* Effect.logWarning("agent activity publish retry budget exhausted", { threadId }); return; } @@ -819,7 +843,9 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, drain: worker.drain, - requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), + requestCatchUp: Effect.fn("AgentAwarenessRelay.requestCatchUp")(function () { + return Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid); + }), start, }); }); diff --git a/apps/server/src/relay/HeldHooksWaker.ts b/apps/server/src/relay/HeldHooksWaker.ts index 5046d62dccfdd..9fee885aa8199 100644 --- a/apps/server/src/relay/HeldHooksWaker.ts +++ b/apps/server/src/relay/HeldHooksWaker.ts @@ -33,8 +33,7 @@ export const layer = Layer.effectDiscard( ), ); yield* runtime.tunnelConnected.pipe( - // cloudflared registers several connections per (re)connect within a - // few seconds; they are one wake. + // Coalesce cloudflared's several connections per reconnect into one wake. Stream.debounce("3 seconds"), Stream.runForEach(() => wake), Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index ddd31817c4ce5..4f3e88e81bd65 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -322,7 +322,7 @@ export function requireDesktopTelemetryWriteProgress( : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -458,8 +458,7 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") if (config.desktopTelemetryFd !== undefined) { const fd = config.desktopTelemetryFd; - // The parent waits for this server before closing its pipe. Socket reads - // can be cancelled; filesystem reads would keep process.exit waiting. + // The parent waits before closing; use cancellable socket reads so filesystem reads cannot block exit. const readable = yield* Effect.acquireRelease( Effect.try({ try: () => new NodeNet.Socket({ fd, readable: true, writable: false }), @@ -638,16 +637,25 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, - requestDesktopUpdate: (requestId) => - sendControlMessage({ - version: 1, - type: "requestDesktopUpdate", - requestId, - }), - commitDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }), - cancelDesktopUpdate: (requestId) => - sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")( + function (requestId) { + return sendControlMessage({ + version: 1, + type: "requestDesktopUpdate", + requestId, + }); + }, + ), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }); + }, + ), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")( + function (requestId) { + return sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }); + }, + ), desktopUpdates: Effect.gen(function* () { const subscription = yield* PubSub.subscribe(updateReportChanges); const initial = yield* Ref.get(latestUpdateReport); @@ -695,10 +703,18 @@ export const layerTest = ( changes: Stream.empty, })), ), - setDiagnosticsDemand: () => Effect.void, - requestDesktopUpdate: () => Effect.void, - commitDesktopUpdate: () => Effect.void, - cancelDesktopUpdate: () => Effect.void, + setDiagnosticsDemand: Effect.fn("DesktopTelemetryReceiver.setDiagnosticsDemand")(function () { + return Effect.void; + }), + requestDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.requestDesktopUpdate")(function () { + return Effect.void; + }), + commitDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.commitDesktopUpdate")(function () { + return Effect.void; + }), + cancelDesktopUpdate: Effect.fn("DesktopTelemetryReceiver.cancelDesktopUpdate")(function () { + return Effect.void; + }), desktopUpdates: overrides.desktopUpdates ?? Effect.succeed({ diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 1cc53e1de2f13..4788689046af3 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -32,8 +32,7 @@ function darwinAvailableMemory(output: string): number | null { const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; if (!pageSize || !free || !inactive || !speculative) return null; - // vm_stat subtracts speculative pages from its printed "Pages free" count. - // Adding them here counts each reclaimable page once; purgeable pages overlap. + // vm_stat excludes speculative from free; add it once, without overlapping purgeable pages. const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; } diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 6f4a019a8ac78..073988ecfd0ba 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -361,7 +361,7 @@ function canCommandNativeTelemetrySidecar( return hasHandle && (status === "healthy" || status === "degraded"); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1088,14 +1088,19 @@ export const layerTest = ( processTree: true, }), snapshots: Stream.empty, - readHistory: () => - Effect.fail( + readHistory: Effect.fn("NativeTelemetryClient.readHistory")(function () { + return Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor history was configured for this test.", }), - ), - setExternalProcesses: () => Effect.void, - setHostPowerState: () => Effect.void, + ); + }), + setExternalProcesses: Effect.fn("NativeTelemetryClient.setExternalProcesses")(function () { + return Effect.void; + }), + setHostPowerState: Effect.fn("NativeTelemetryClient.setHostPowerState")(function () { + return Effect.void; + }), sampleNow: Effect.fail( new NativeTelemetryUnavailable({ reason: "No resource monitor sample was configured for this test.", diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts index 694803b648420..f10937c4ebd43 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.test.ts @@ -1,7 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; -import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import type { ResourceTelemetryProcess, ResourceTelemetrySnapshot } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; -import { groupProcesses } from "./ProcessGroupMetrics.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProcessGroupMetrics from "./ProcessGroupMetrics.ts"; +import * as ResourceTelemetry from "./ResourceTelemetry.ts"; + +const { groupProcesses } = ProcessGroupMetrics; const process = ( pid: number, @@ -57,3 +67,54 @@ describe("groupProcesses", () => { }); }); }); + +describe("ProcessGroupMetrics.layer", () => { + it.effect("logs the first failed sample and the recovery, not every repeat", () => { + const logs: Array = []; + const logger = Logger.make(({ logLevel, message }) => { + logs.push(`${logLevel.toUpperCase()} ${String([message].flat()[0])}`); + }); + return Effect.gen(function* () { + const outcomes = yield* Ref.make>(["fail", "fail", "ok"]); + const refresh = Ref.modify(outcomes, ([next = "ok", ...rest]) => [next, rest] as const).pipe( + Effect.flatMap((next) => + next === "fail" + ? Effect.fail( + new ResourceTelemetry.ResourceTelemetryRefreshFailed({ + operation: "refresh", + cause: new Error("Resource monitor binary was not found for darwin/arm64."), + }), + ) + : Effect.succeed({ processes: [] } as unknown as ResourceTelemetrySnapshot), + ), + ); + const telemetry = { refresh } as unknown as ResourceTelemetry.ResourceTelemetry["Service"]; + + yield* Layer.build( + ProcessGroupMetrics.layer.pipe( + Layer.provide(Layer.succeed(ResourceTelemetry.ResourceTelemetry, telemetry)), + Layer.provide( + ServerConfig.layer({ + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + } as ServerConfig.ServerConfig["Service"]), + ), + ), + ); + yield* TestClock.adjust("3 minutes"); + + assert.deepStrictEqual(logs, [ + "WARN process group metrics are not exported: sample failed", + "DEBUG process group metrics sample failed", + "INFO process group metrics sample recovered", + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + Logger.layer([logger], { mergeWithExisting: false }), + Layer.succeed(References.MinimumLogLevel, "Debug"), + ), + ), + ); + }); +}); diff --git a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts index 6f8f29c37161f..b3a02e94e244a 100644 --- a/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts +++ b/apps/server/src/resourceTelemetry/ProcessGroupMetrics.ts @@ -1,7 +1,9 @@ import type { ResourceTelemetryProcess } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; +import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; import { metricAttributes } from "../observability/Metrics.ts"; @@ -113,10 +115,28 @@ export const layer = Layer.effectDiscard( yield* Metric.update(Metric.withAttributes(processCount, attributes), total.count); } }); - yield* sample().pipe( - Effect.catch((error) => - Effect.logDebug("process group metrics sample failed", { error: error.message }), + // A failing collector would otherwise leave these series silently absent, so the first + // failure and the recovery are logged; the repeats stay at debug. + const failing = yield* Ref.make(false); + const recordSuccess = Ref.getAndSet(failing, false).pipe( + Effect.flatMap((wasFailing) => + wasFailing ? Effect.logInfo("process group metrics sample recovered") : Effect.void, ), + ); + const recordFailure = (error: ResourceTelemetry.ResourceTelemetryRefreshFailed) => { + const cause = Cause.fail(error); + return Ref.getAndSet(failing, true).pipe( + Effect.flatMap((wasFailing) => + wasFailing + ? Effect.logDebug("process group metrics sample failed", cause) + : Effect.logWarning("process group metrics are not exported: sample failed", cause), + ), + Effect.annotateLogs({ operation: error.operation }), + ); + }; + yield* sample().pipe( + Effect.andThen(recordSuccess), + Effect.catch(recordFailure), Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)), Effect.forever, Effect.forkScoped, diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 6eb0a5fd28610..232d89b7aa50c 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -8,8 +8,7 @@ import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; -// The override checks POSIX exec bits on a real file under a linux platform -// mock; NTFS never reports those bits, so the check cannot be satisfied there. +// NTFS lacks POSIX exec bits required by this Linux-mocked override check. const windowsHost = HostProcess.Platform.defaultValue() === "win32"; describe("ResourceMonitorBinary", () => { diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index d582698d9ce30..f249197d8510a 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -189,8 +189,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const stat = yield* fileSystem.stat(candidate).pipe(Effect.option); if (Option.isSome(stat)) { - // Directories are executable-looking on win32, where the exec-bit test - // below is skipped, and only fail later at spawn. + // Windows skips exec-bit checks, so directories would otherwise pass until spawn. const unusable = stat.value.type === "Directory" || (platform !== "win32" && (stat.value.mode & 0o111) === 0); diff --git a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts index 2af6d98da0d19..7516640dca922 100644 --- a/apps/server/src/resourceTelemetry/ResourceTelemetry.ts +++ b/apps/server/src/resourceTelemetry/ResourceTelemetry.ts @@ -142,7 +142,7 @@ function buildHealth(input: { }; } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("resourceTelemetry.resourceTelemetry.make")(function* () { const nativeClient = yield* NativeTelemetryClient.NativeTelemetryClient; const desktopReceiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index a9fb05ab14395..4df56454cfc58 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -23,7 +23,6 @@ function layer(input: { readonly previousWorktreesDirectories?: ReadonlyArray; readonly projectRoots?: ReadonlyArray | "unavailable"; readonly threadWorktrees?: ReadonlyArray<{ worktreePath: string; deleted?: boolean }>; - /** What `git worktree list` reports for every project. */ readonly gitWorktrees?: ReadonlyArray; }) { const projectRoots = input.projectRoots ?? []; @@ -175,7 +174,6 @@ describe("ReviewService", () => { assert.strictEqual((yield* review(projectRoot, [projectRoot])).cwd, projectRoot); assert.strictEqual((yield* review(projectChild, [projectRoot])).cwd, projectChild); - // A child whose name starts with `..` is still inside. const dottedChild = `${projectRoot}/..feature`; yield* fs.makeDirectory(dottedChild); assert.strictEqual((yield* review(dottedChild, [projectRoot])).cwd, dottedChild); @@ -196,11 +194,9 @@ describe("ReviewService", () => { const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); const project = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-project-" }); - // Checkouts outside every managed folder, as `git worktree add ../feature` makes. const bound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-bound-" }); const deleted = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-deleted-" }); const unbound = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-unbound-" }); - // A client can set a thread's path to any folder; git doesn't list this one. const claimed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-claimed-" }); yield* fs.makeDirectory(`${bound}/sub`); @@ -219,9 +215,7 @@ describe("ReviewService", () => { { worktreePath: bound }, { worktreePath: claimed }, { worktreePath: deleted, deleted: true }, - // A relative path would resolve against the server's own cwd. { worktreePath: "t3-review-relative" }, - // A filesystem root would make every folder look bound. { worktreePath: "/" }, ], }), diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 488cc84be0a70..48c412a4bbc72 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -32,7 +32,7 @@ export class ReviewService extends Context.Service< } >()("t3/review/ReviewService") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/rpcInitialItems.ts b/apps/server/src/rpcInitialItems.ts index 172a3a897a32a..6f3409858920f 100644 --- a/apps/server/src/rpcInitialItems.ts +++ b/apps/server/src/rpcInitialItems.ts @@ -6,8 +6,7 @@ export function rpcInitialItems(items: ReadonlyArray): Stream.Stream { return Stream.unwrap( Effect.sync(() => { const initial = pending; - // concat keeps its initial stream definition alive during the live tail. - // That definition must no longer own delivered snapshots or replay rows. + // concat retains the initial stream during live tailing; release delivered snapshots and rows. pending = undefined; return initial === undefined ? Stream.empty : Stream.fromIterable(initial); }), diff --git a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts index abf7c148acc78..0f33869c387ea 100644 --- a/apps/server/src/scheduledTasks/RelayDeliveryProof.ts +++ b/apps/server/src/scheduledTasks/RelayDeliveryProof.ts @@ -1,9 +1,4 @@ -/** - * The relay's delivery id, receive time and trace context arrive as plain - * headers, and the webhook URL can also be called directly, so they are only - * trusted alongside a proof the relay signed with its mint key. A request - * without a valid proof is handled as a direct request. - */ +// Trust relay headers only with a mint-key-signed proof; otherwise handle the webhook as a direct request. import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay"; import { normalizeRelayIssuer, diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index 4cabb35393361..bac32bb913146 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -15,8 +15,6 @@ export function nextScheduledRunAt( ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; if (schedule.type === "interval") { - // Persisted rows created before the one-minute floor remain readable, but - // they must not retain their old high-frequency execution rate. // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); } @@ -51,9 +49,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul } if (a.type === "webhook") return b.type === "webhook"; if (b.type !== "fixed_time") return false; - // The contract accepts padded and unpadded hours ("9:00" and "09:00"), so - // compare the parsed time — string equality would treat a format-only edit - // as a schedule change and recompute the pending run. + // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); const bTime = parseTimeOfDay(b.timeOfDay); return ( @@ -75,23 +71,3 @@ export function isMissedFixedTimeRun( if (schedule.type !== "fixed_time") return false; return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS; } - -function describeSchedule(schedule: ScheduledTaskSchedule): string { - if (schedule.type === "webhook") return "On webhook"; - if (schedule.type === "interval") { - const minutes = schedule.everyMs / MINUTE_MS; - if (Number.isInteger(minutes)) { - return `Every ${minutes === 1 ? "minute" : `${minutes} minutes`}`; - } - return `Every ${Math.round(schedule.everyMs / 1000)} seconds`; - } - - const weekdayCount = schedule.weekdays?.length ?? 0; - const days = - weekdayCount === 0 - ? "day" - : weekdayCount === 5 && schedule.weekdays?.every((day) => day >= 1 && day <= 5) - ? "weekday" - : "selected day"; - return `At ${schedule.timeOfDay} every ${days}`; -} diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 466af14e7f229..c1dcdd22abbae 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -179,8 +179,6 @@ it.effect( yield* insertRow(sql, { ...row, enabled: 1 }, now); } - // it.effect freezes the clock; pin it just past the seeded due time so - // the poll sees the rows as due. yield* TestClock.setTime(Date.parse(now) + 1_000); const dispatched = yield* Ref.make(0); const lastDispatched = yield* Deferred.make(); @@ -215,8 +213,7 @@ it.effect( ), ), ); - // The poll loop delays before its first pass, so the frozen test - // clock must advance once to trigger it. + // The poll delays before its first pass; advance the frozen clock once. yield* TestClock.adjust("6 seconds"); yield* Deferred.await(lastDispatched); const inflight = yield* sql<{ diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index a4714e3ae417d..3dfedb0dfeaf4 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -78,7 +78,7 @@ const WEBHOOK_FILTERED_LOG_PER_MINUTE = 10; const WEBHOOK_DELIVERY_LOG_BODY_LIMIT = 64 * 1024; const WEBHOOK_DELIVERY_LOG_PROMPT_LIMIT = 64 * 1024; const WEBHOOK_MAX_QUEUED_PER_TASK = 20; -/** Accepted deliveries per task per minute, enforced here as well as on the relay because the tunnel hostname is public too. */ +// Enforce the relay's per-task/minute limit here too; tunnel hostnames are public. const WEBHOOK_RATE_LIMIT_PER_MINUTE = 60; const LEGACY_DELIVERY_OUTCOMES: ReadonlySet = new Set([ "accepted", @@ -256,11 +256,7 @@ export class ScheduledTaskService extends Context.Service< readonly getWebhookDelivery: ( input: ScheduledTaskGetWebhookDeliveryInput, ) => Effect.Effect; - /** - * Verifies, logs and dispatches one webhook request. Returns as soon as the - * delivery is logged; the run itself continues in the background so senders - * with short timeouts get their answer immediately. - */ + /** Verifies, logs and dispatches; returns after logging while the run continues in the background. */ readonly triggerWebhook: ( request: WebhookTriggerRequest, ) => Effect.Effect; @@ -286,8 +282,7 @@ function nextRunAt( from: DateTime.DateTime, ): string | null { if (!task.enabled) return null; - // A stored interval can decode yet overflow the representable DateTime - // range; an unrepresentable occurrence means the task has no next run. + // Decoded intervals can overflow DateTime; unrepresentable occurrences have no next run. try { const next = nextScheduledRunAt(task.schedule, from); return next !== null && Number.isFinite(DateTime.toEpochMillis(next)) ? iso(next) : null; @@ -377,9 +372,7 @@ export const listDueTasks = Effect.fn("ScheduledTaskService.listDueTasks")(funct const decoded = yield* Effect.result(decodeRow(row)); if (Result.isSuccess(decoded)) { const task = decoded.success; - // next_run_at is a freeform string at the schema level; a stored value - // that cannot parse as a DateTime would defect the poll below, so the - // row is skipped here like any other corrupt row. + // next_run_at is freeform; skip unparseable values before they defect the poll. if (task.nextRunAt === null || Option.isSome(DateTime.make(task.nextRunAt))) { tasks.push(task); } else { @@ -411,8 +404,7 @@ export const layer = Layer.effect( const webhookPermits = yield* Ref.make>( new Map(), ); - // Keyed by task id and creation time, so deliveries of a deleted task that - // finish late release their own count, never a recreated task's. + // Include creation time so late deliveries cannot decrement a recreated task's count. const webhookQueued = yield* Ref.make>(new Map()); const webhookRateWindows = yield* Ref.make>(new Map()); const webhookRejectedRateWindows = yield* Ref.make>( @@ -426,9 +418,7 @@ export const layer = Layer.effect( ReadonlyMap> >(new Map()); const activeRuns = yield* Ref.make>(new Set()); - // Sliding(1) coalesces the dirty-signal: every notification triggers a - // full list() re-emit anyway, so a slow subscriber only ever needs the - // latest signal — an unbounded backlog would just grow memory. + // Notifications re-emit the full list; slow subscribers need only the newest dirty signal. const changesPubSub = yield* PubSub.sliding(1); const notifyChanged = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); @@ -534,11 +524,8 @@ export const layer = Layer.effect( return task; }); - // Run-state columns (last_run_*, run_count) are intentionally absent from - // the conflict clause: they are owned by the run transitions below, and a - // concurrent settings save must not overwrite an in-flight increment. - // Check existence in the write itself so an edit cannot undo a deletion - // that landed after upsert loaded the previous task. + // Run transitions own run-state columns; settings saves must not overwrite them. + // Check existence in the write so edits cannot undo concurrent deletion. const saveTask = ( task: ScheduledTask, requireExisting: boolean, @@ -691,12 +678,9 @@ export const layer = Layer.effect( ), ); - // Best-effort escape hatch: if anything fails between markRunning and - // markCompleted, write a full terminal record so runDueTasks neither skips - // the task forever (it filters out 'running' rows) nor re-fires it - // immediately: the dispatch may already have gone out, so next_run_at must - // advance. Pending webhook attempts count only with their completion state; - // other attempts count here. + // A failure between markRunning and markCompleted must still write a terminal record: + // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, + // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { const now = yield* localNow; @@ -754,9 +738,7 @@ export const layer = Layer.effect( const startedAt = yield* localNow; const startedAtIso = iso(startedAt); - // The in-memory snapshot may be stale: re-read before touching run - // state. The task may have been deleted, paused, or postponed since - // the poll loaded it — none of those may fire. + // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { if (webhook !== undefined) { @@ -786,9 +768,7 @@ export const layer = Layer.effect( ) { return { task: active, threadId: null }; } - // A queued delivery must not run a task that was paused, deleted and - // recreated under the same id, or switched to another trigger while - // it waited for its turn. + // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { const reason = active.createdAt !== task.createdAt @@ -828,9 +808,7 @@ export const layer = Layer.effect( // A webhook prompt was rendered from the row when the request arrived. const prompt = webhook?.prompt ?? active.prompt; - // Effect.exit (not Effect.result) so defects and interruptions in the - // dispatch are also captured and recorded as a failed run instead of - // aborting before markCompleted. + // Capture defects and interrupts too so dispatch failures still reach markCompleted. const result = active.threadId === null ? yield* Effect.exit( @@ -985,8 +963,7 @@ export const layer = Layer.effect( ); }); - // Recover from a crash or hard shutdown mid-run: rows stuck in 'running' - // would otherwise be skipped by the due-task filter forever. + // Recover crash-left running rows that the due-task filter would otherwise skip forever. yield* Effect.gen(function* () { const rows = yield* selectAllRows(); const stuck = rows.filter((row) => row.last_run_status === "running"); @@ -1050,8 +1027,7 @@ export const layer = Layer.effect( const subscribeList: ScheduledTaskService["Service"]["subscribeList"] = () => Stream.unwrap( Effect.gen(function* () { - // Subscribe before taking the snapshot so a change landing between - // the two is buffered by the subscription rather than dropped. + // Subscribe before reading to buffer concurrent changes. const subscription = yield* PubSub.subscribe(changesPubSub); return Stream.concat( Stream.fromEffect(list()), @@ -1133,7 +1109,7 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => replay - ? Effect.succeed(undefined) + ? Effect.void : Effect.fail(taskError(error.message, { taskId: id })), ), ); @@ -1433,8 +1409,7 @@ export const layer = Layer.effect( return sql .withTransaction( Effect.gen(function* () { - // Conditional on the task existing, so a delivery racing a delete - // cannot leave rows that a recreated task with the same id would show. + // Require the task to exist so deletes cannot leave deliveries for a recreated task. yield* sql` INSERT INTO scheduled_task_webhook_deliveries ( delivery_id, task_id, received_at, method, query, headers_json, body, diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index 65cecbcf43286..48d3024d4f938 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -300,7 +300,6 @@ it.effect("dispatches exactly the rendered prompt and logs the delivery", () => assert.equal(delivery.body, new TextDecoder().decode(pullRequestBody)); assert.equal(delivery.renderedPrompt, launched.initialMessage?.text); - // Each delivery gets its own frame, so a request can't predict the tag that closes it. yield* service.triggerWebhook(requestFor(task)); const next = framed.exec((yield* Queue.take(launches)).initialMessage?.text ?? "")?.[1]; assert.isDefined(next); @@ -504,6 +503,20 @@ it.effect.each(queuedDeliveryCases)( ({ reason, apply }) => Effect.gen(function* () { const gate = yield* Deferred.make(); + const deliveryReleased = yield* Deferred.make(); + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (...args) => { + end(...args); + if (span.name === "ScheduledTaskService.releaseFailedDelivery") { + Deferred.doneUnsafe(deliveryReleased, Effect.void); + } + }; + return span; + }, + }); yield* withService( ({ service, launches }) => Effect.gen(function* () { @@ -514,23 +527,17 @@ it.effect.each(queuedDeliveryCases)( yield* apply(service, task.id); yield* Deferred.succeed(gate, undefined); const deliveryId = queued._tag === "accepted" ? queued.deliveryId : undefined; - let delivery = (yield* service.getWebhookDelivery({ + yield* Deferred.await(deliveryReleased); + const delivery = (yield* service.getWebhookDelivery({ id: task.id, deliveryId: deliveryId!, })).delivery; - while (delivery.outcome === "accepted") { - yield* Effect.yieldNow; - delivery = (yield* service.getWebhookDelivery({ - id: task.id, - deliveryId: deliveryId!, - })).delivery; - } assert.equal(delivery.outcome, "dispatch_failed"); assert.equal(delivery.error, reason); assert.equal(yield* Queue.size(launches), 0); }), { gate }, - ); + ).pipe(Effect.withTracer(tracer)); }), ); diff --git a/apps/server/src/scheduledTasks/webhookRoute.ts b/apps/server/src/scheduledTasks/webhookRoute.ts index 2566349d273b6..e43d9d81c930d 100644 --- a/apps/server/src/scheduledTasks/webhookRoute.ts +++ b/apps/server/src/scheduledTasks/webhookRoute.ts @@ -17,7 +17,7 @@ import * as ScheduledTaskService from "./ScheduledTaskService.ts"; /** The relay enforces the same cap. */ export const WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; -/** Response header naming what happened to the request; the relay records it. */ +// The relay records this outcome header. const WEBHOOK_OUTCOME_HEADER = "x-t3-hook-outcome"; const json = (status: number, body: Record, outcome: string) => diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index 6aa23e12b0363..eaee34f52da39 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -54,7 +54,6 @@ describe("renderWebhookPrompt", () => { { ...githubPullRequest, bodyText: JSON.stringify({ text }) }, "id", ); - // The adapters' pattern finds only the template's own mention. const mentions = [...rendered.prompt.matchAll(SKILL_MENTION_PATTERN)].map((match) => match[2]); assert.deepEqual(mentions, ["review"]); // Codex and Pi read `$name` after any character, digits included. diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 84e2599dc0d75..fc43dbff921ed 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -1,14 +1,5 @@ -/** - * Anyone who can call the URL chooses these values, so each one is wrapped in - * `` tags and a closing note marks the tagged text as - * untrusted request data. The caller picks a fresh random ID per request, so a - * request can't close its own frame. Mentions inside a value are broken so - * they never act; the template's own mentions still do. - * - * Credential-looking headers and query parameters are redacted wherever the - * whole set renders (`{{request}}`, `{{headers}}`, `{{query}}`), as in the - * delivery log. Naming one (`{{headers.authorization}}`) gives its raw value. - */ +// Frame untrusted values with unpredictable per-request tags and break their mentions, preserving template mentions. +// Whole-set placeholders redact credentials; named placeholders return raw values. export interface WebhookRequest { readonly method: string; @@ -56,17 +47,8 @@ export function redactQuery(query: string): string { // Trim after matching so unclosed placeholders cannot backtrack over whitespace partitions. const PLACEHOLDER = /\{\{([^{}]*)\}\}/g; -/** - * Syntax an adapter or provider CLI may act on before the model reads the - * prompt: `$name` skill mentions (any currency sign, after any character in - * Codex and Pi), `@` file, agent and plugin mentions (after punctuation or - * letters in some CLIs), a `/name` command word mid-text, and `t3-context:` - * links. A word joiner goes after every currency sign and `@`, after a `/` - * that starts a single-segment word, and inside the `t3-context` scheme and - * any `webhook-data` name, so a value can't forge a frame tag. - * Checked against Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh - * 0.2.0-rc.2; other agents may parse more. - */ +// Break mentions before provider CLI parsing, including currency signs, embedded @, commands, context links and frame tags. +// Checked with Claude Code 2.1.296, Codex 0.162.1, Pi 1.2.0 and dsh 0.2.0-rc.2; others may parse more. const MENTION = /[\p{Sc}@]|(?<=^|\s)\/(?=[^\s/]+(?:\s|$))|t3-context(?=:)|webhook(?=-data)/giu; function parseBody(request: WebhookRequest): unknown { @@ -74,8 +56,7 @@ function parseBody(request: WebhookRequest): unknown { if (contentType.includes("application/x-www-form-urlencoded")) { return Object.fromEntries(new URLSearchParams(request.bodyText)); } - // Senders are inconsistent about content types, so any body that parses - // as JSON is addressable. + // Senders mislabel content types; accept any parseable JSON body. try { return JSON.parse(request.bodyText) as unknown; } catch { @@ -183,7 +164,6 @@ export function renderWebhookPrompt( if (!missing.includes(expression)) missing.push(expression); return ""; } - // An empty value carries no request data, so it needs no frame or note. if (text === "") return ""; if (raw) return text; framed = true; diff --git a/apps/server/src/secrets/SecretRequests.test.ts b/apps/server/src/secrets/SecretRequests.test.ts index ffb8cd314d873..8962e5d4103d0 100644 --- a/apps/server/src/secrets/SecretRequests.test.ts +++ b/apps/server/src/secrets/SecretRequests.test.ts @@ -1,18 +1,23 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { ProjectId, type OrchestrationV2ServerCommand, + SecretRef, ThreadId, TurnItemId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Tracer from "effect/Tracer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; @@ -30,6 +35,8 @@ const withService = ( }) => Effect.Effect, options: { readonly threadId?: ThreadId; + readonly delivery?: "file"; + readonly directory?: string; readonly runStatus?: string; readonly removeFails?: boolean; readonly closedFirst?: boolean; @@ -48,6 +55,7 @@ const withService = ( Layer.succeed( ServerSecretStore.ServerSecretStore, ServerSecretStore.ServerSecretStore.of({ + ...(options.directory === undefined ? {} : { directory: options.directory }), get: (name) => Effect.yieldNow.pipe(Effect.as(Option.fromNullishOr(stored.get(name)))), set: (name, value) => Effect.sync(() => void stored.set(name, value)), create: (name, value) => @@ -98,6 +106,7 @@ const withService = ( type: "secret_request", label: "GitHub token", reason: "Used as GH_TOKEN.", + ...(options.delivery === undefined ? {} : { delivery: options.delivery }), secretStatus, }, ], @@ -163,10 +172,9 @@ it.effect("two concurrent uses of one ref hand the value out once", () => answer: { type: "save", secret: "ghp_secret" }, }); const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId })); - const results = yield* Effect.all( - [service.consume({ ref, projectId }), service.consume({ ref, projectId })].map( - Effect.result, - ), + const results = yield* Effect.forEach( + [service.consume({ ref, projectId }), service.consume({ ref, projectId })], + Effect.result, { concurrency: "unbounded" }, ); assert.deepEqual(results.map((result) => result._tag).toSorted(), ["Failure", "Success"]); @@ -338,3 +346,113 @@ it.effect("a used value that cannot be deleted is not handed out", () => { removeFails: true }, ), ); + +const SECRET_FILE_TTL_MS = 24 * 60 * 60 * 1000; + +it.effect("a file request moves its value to an owner-only file, once", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + const file = yield* withService( + ({ service, stored, dispatched }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token'$(id)" }, + }); + assert.include(dispatched[0] as object, { secretStatus: "saved", delivery: "file" }); + const file = yield* service.saveToFile({ threadId, turnItemId }); + assert.equal(yield* fs.readFileString(file), "cf_token'$(id)"); + assert.deepEqual(valuesOf(stored), []); + if ((yield* HostProcess.Platform) !== "win32") { + assert.equal((yield* fs.stat(file)).mode & 0o777, 0o600); + assert.equal((yield* fs.stat(path.dirname(file))).mode & 0o777, 0o700); + } + // A retried request_secret gets the same file; the value is never a ref again. + assert.equal(yield* service.saveToFile({ threadId, turnItemId }), file); + const ref = SecretRef.make(`secret-ref:${path.basename(file)}`); + const reused = yield* service.consume({ ref, projectId }).pipe(Effect.flip); + assert.equal(reused.reason, "ref_unavailable"); + return file; + }), + { delivery: "file", directory }, + ); + + const { mtime } = yield* fs.stat(file); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS); + yield* withService(() => Effect.void, { directory }); + assert.isTrue(yield* fs.exists(file)); + yield* TestClock.setTime(Option.getOrThrow(mtime).getTime() + SECRET_FILE_TTL_MS + 1); + yield* withService(() => Effect.void, { directory }); + assert.isFalse(yield* fs.exists(file)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a failed file write keeps the value, so the request can retry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + yield* withService( + ({ service }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token" }, + }); + const ref = Option.getOrThrow(yield* service.savedRef({ threadId, turnItemId })); + const name = ref.slice("secret-ref:".length); + // A non-empty directory where the partial file goes makes the write fail. + const partial = path.join(directory, "files", `${name}.partial`); + yield* fs.makeDirectory(path.join(partial, "block"), { recursive: true }); + const failed = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(failed.reason, "store_failed"); + yield* fs.remove(partial, { recursive: true }); + const file = yield* service.saveToFile({ threadId, turnItemId }); + assert.equal(yield* fs.readFileString(file), "cf_token"); + }), + { delivery: "file", directory }, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a file is not delivered while its stored copy cannot be removed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "secret-files-test-" }); + yield* withService( + ({ service }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "cf_token" }, + }); + const first = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(first.reason, "consume_failed"); + // A retry finds the file but still will not hand it out until the removal works. + const retried = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(retried.reason, "consume_failed"); + }), + { delivery: "file", directory, removeFails: true }, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("a secret the card promised to keep from the agent is never written to a file", () => + withService(({ service, stored }) => + Effect.gen(function* () { + yield* service.answer({ + threadId, + turnItemId, + answer: { type: "save", secret: "ghp_secret" }, + }); + const refused = yield* service.saveToFile({ threadId, turnItemId }).pipe(Effect.flip); + assert.equal(refused.reason, "not_a_file_request"); + assert.isTrue(valuesOf(stored).some((value) => value.includes("ghp_secret"))); + }), + ), +); diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 4470c257f67b5..7c8e961dbd2eb 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,9 +1,6 @@ -/** - * The user's answer goes straight to the server's secret store under a - * one-use SecretRef; orchestration only records the request and its status. - * A tool that needs the value takes the ref and consumes it, so the value - * never reaches the transcript, projections, clients, or model context. - */ +// Answers stay in the secret store behind one-use refs; orchestration records only request status. +// Consuming tools keep values out of transcripts, projections, clients and model context. +// A `file` request instead moves its value to an owner-only file for the agent's own shell. import { CommandId, SecretRef, @@ -21,6 +18,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -34,6 +32,7 @@ const storeName = (ref: SecretRef) => `secret-request-${ref.slice(SECRET_REF_PRE const REF_PATTERN = /^secret-ref:[0-9a-f]{32}$/; const SECRET_REF_TTL_MS = 24 * 60 * 60 * 1000; const STORE_NAME_PATTERN = /^(secret-request-[0-9a-f]{32})\.bin$/; +const FILE_NAME_PATTERN = /^[0-9a-f]{32}(\.partial)?$/; const refFor = (salt: string, threadId: ThreadId, turnItemId: string) => SecretRef.make( @@ -61,13 +60,21 @@ export class SecretRequests extends Context.Service< readonly ref: SecretRef; readonly projectId: ProjectId; }) => Effect.Effect; + /** Moves a saved `file` request's value to an owner-only file and returns its path. */ + readonly saveToFile: (input: { + readonly threadId: ThreadId; + readonly turnItemId: string; + }) => Effect.Effect; } >()("t3/secrets/SecretRequests") {} const make = Effect.gen(function* () { const store = yield* ServerSecretStore.ServerSecretStore; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const threadManagement = yield* ThreadManagementService.ThreadManagementService; + // Beside the store's own files so the same owner-only directory and cleanup rules apply. + const filesDir = store.directory === undefined ? undefined : path.join(store.directory, "files"); const salt = Buffer.from( yield* store.getOrCreateRandom("secret-request-salt", 32).pipe(Effect.orDie), @@ -96,10 +103,8 @@ const make = Effect.gen(function* () { if (run === undefined || ThreadManagementService.isTerminalRunStatus(run.status)) { return yield* new SecretRequestError({ reason: "agent_stopped" }); } - // Store first: the card only says saved once the value is kept. Create, - // not set: a second answer racing this one must not replace the value. - // A value already there on a card still pending is an earlier save whose - // record failed and could not be cleaned up; recording it finishes that save. + // Create before recording so racing answers cannot overwrite values or show saved before storage. + // Existing values on pending cards finish earlier saves whose recording failed. if (input.answer.type === "save") { const encoded = yield* encodeStored({ projectId: records.thread.projectId, @@ -130,12 +135,12 @@ const make = Effect.gen(function* () { label: item.label, reason: item.reason, ...(item.placeholder === undefined ? {} : { placeholder: item.placeholder }), + ...(item.delivery === undefined ? {} : { delivery: item.delivery }), secretStatus, }) .pipe( Effect.mapError((cause) => new SecretRequestError({ reason: "record_failed", cause })), - // The card still says pending, so the user can save again; the value - // stored above would make that retry look already answered. + // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" ? removeLogged(storeName(refFor(salt, input.threadId, item.id))) @@ -143,9 +148,7 @@ const make = Effect.gen(function* () { ), ); if (input.answer.type !== "save") return; - // A request is answered once: if the agent's wait closed the card between - // the checks above and this record, the record changed nothing. Nobody - // will receive the ref, so the value is deleted rather than left to expire. + // The wait may have closed the card; delete unrecorded answers because no one receives their refs. const recorded = yield* threadManagement .getThreadRecords(input.threadId, ["turnItems"], { turnItemTypes: ["secret_request"], @@ -180,8 +183,7 @@ const make = Effect.gen(function* () { ), ); - // get and remove are separate store calls; one consumer at a time keeps two - // concurrent calls from both reading a ref before either deletes it. + // Serialize get/remove so concurrent consumers cannot read the same one-use ref. const consumeLock = yield* Semaphore.make(1); const consume: SecretRequests["Service"]["consume"] = (input) => consumeRef(input).pipe( @@ -193,7 +195,8 @@ const make = Effect.gen(function* () { Effect.withSpan("SecretRequests.consume"), ); - const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + /** A live ref's value, left in the store. */ + const readRef = Effect.fn("SecretRequests.readRef")(function* (input: { readonly ref: SecretRef; readonly projectId: ProjectId; }) { @@ -212,12 +215,72 @@ const make = Effect.gen(function* () { yield* removeLogged(storeName(input.ref)); return yield* new SecretRequestError({ reason: "ref_expired" }); } + return decoded.value.value; + }); + + const consumeRef = Effect.fn("SecretRequests.consumeRef")(function* (input: { + readonly ref: SecretRef; + readonly projectId: ProjectId; + }) { + const value = yield* readRef(input); // One use: the value moves into whatever consumed it. If it cannot be // deleted, it is not handed out, so a ref is never used twice. if (!(yield* removeLogged(storeName(input.ref)))) { return yield* new SecretRequestError({ reason: "consume_failed" }); } - return decoded.value.value; + return value; + }); + + const saveToFile: SecretRequests["Service"]["saveToFile"] = Effect.fn( + "SecretRequests.saveToFile", + )(function* (input) { + const records = yield* threadManagement + .getThreadRecords(input.threadId, ["turnItems"], { + turnItemTypes: ["secret_request"], + messageRoles: [], + }) + .pipe(Effect.mapError((cause) => new SecretRequestError({ reason: "load_failed", cause }))); + const item = records.turnItems.find((candidate) => candidate.id === input.turnItemId); + // Only a card that told the user the agent's commands can read the value may write it out. + if ( + item?.type !== "secret_request" || + item.secretStatus !== "saved" || + item.delivery !== "file" + ) { + return yield* new SecretRequestError({ reason: "not_a_file_request" }); + } + if (filesDir === undefined) return yield* new SecretRequestError({ reason: "store_failed" }); + const ref = refFor(salt, input.threadId, item.id); + const file = path.join(filesDir, ref.slice(SECRET_REF_PREFIX.length)); + return yield* Effect.gen(function* () { + // One use: the file counts as delivered only once the stored copy is gone. A retry with the + // same clientRequestId gets the file the first call wrote and finishes that removal. + const removeStored = removeLogged(storeName(ref)).pipe( + Effect.flatMap((removed) => + removed ? Effect.succeed(file) : new SecretRequestError({ reason: "consume_failed" }), + ), + ); + if (yield* fileSystem.exists(file).pipe(Effect.orElseSucceed(() => false))) { + return yield* removeStored; + } + // The stored value goes only after the file is complete, so a failed write can retry. + const value = yield* readRef({ ref, projectId: records.thread.projectId }); + const partial = `${file}.partial`; + yield* fileSystem.makeDirectory(filesDir, { recursive: true, mode: 0o700 }).pipe( + Effect.andThen(fileSystem.chmod(filesDir, 0o700)), + Effect.andThen(fileSystem.remove(partial, { force: true })), + // `wx` never follows or replaces an existing path. + Effect.andThen( + fileSystem.writeFile(partial, new TextEncoder().encode(value), { + flag: "wx", + mode: 0o600, + }), + ), + Effect.andThen(fileSystem.rename(partial, file)), + Effect.mapError((cause) => new SecretRequestError({ reason: "store_failed", cause })), + ); + return yield* removeStored; + }).pipe(consumeLock.withPermits(1)); }); const sweepExpired = Effect.gen(function* () { @@ -241,14 +304,32 @@ const make = Effect.gen(function* () { Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret refs", cause)), Effect.withSpan("SecretRequests.sweepExpired"), ); - yield* sweepExpired; - yield* sweepExpired.pipe( + const sweepExpiredFiles = Effect.gen(function* () { + if (filesDir === undefined || !(yield* fileSystem.exists(filesDir))) return; + const now = yield* Clock.currentTimeMillis; + let removed = 0; + for (const name of yield* fileSystem.readDirectory(filesDir)) { + if (!FILE_NAME_PATTERN.test(name)) continue; + const file = path.join(filesDir, name); + const { mtime } = yield* fileSystem.stat(file); + if (Option.exists(mtime, (time) => now - time.getTime() <= SECRET_REF_TTL_MS)) continue; + yield* fileSystem.remove(file); + removed += 1; + } + yield* Effect.annotateCurrentSpan({ "secret_request.expired_files_removed": removed }); + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("Could not sweep expired secret files", cause)), + Effect.withSpan("SecretRequests.sweepExpiredFiles"), + ); + const sweep = Effect.andThen(sweepExpired, sweepExpiredFiles); + yield* sweep; + yield* sweep.pipe( Effect.delay("1 hour"), Effect.repeat(Schedule.spaced("1 hour")), Effect.forkScoped, ); - return SecretRequests.of({ answer, savedRef, consume }); + return SecretRequests.of({ answer, savedRef, consume, saveToFile }); }); export const layer = Layer.effect(SecretRequests, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ff0aca4126bc5..f461a99186b31 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -56,10 +56,12 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as OpenCodeRuntime from "@t3tools/provider-opencode/server/OpenCodeRuntime"; import * as OpenCodeServerLedger from "@t3tools/provider-opencode/server/OpenCodeServerLedger"; import * as ProviderHostLive from "./provider/ProviderHostLive.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts"; import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts"; @@ -72,6 +74,7 @@ import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ServerBrowser from "./preview/ServerBrowser.ts"; +import * as PreviewAgentActivity from "./preview/PreviewAgentActivity.ts"; import * as DesktopBrowserChannel from "./preview/DesktopBrowserChannel.ts"; import * as ServerBrowserStream from "./preview/ServerBrowserStream.ts"; import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; @@ -140,6 +143,7 @@ import { } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as CloudHttp from "./cloud/http.ts"; +import * as GuestHttp from "./guest/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; import * as ConnectReachability from "./cloud/ConnectReachability.ts"; import { pendingServiceUpdateExists } from "./cloud/updateHandoff.ts"; @@ -185,7 +189,7 @@ import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; -import { loopbackUrlHostForAddress } from "./startupAccess.ts"; +import { acceptsPrivateNetworkConnection, loopbackUrlHostForAddress } from "./startupAccess.ts"; import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; @@ -193,15 +197,12 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; -// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's -// 100-character default for one path segment. +// Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. const HTTP_ROUTER_CONFIG = { maxParamLength: 512, } as const; -// Effect's default preemptive shutdown waits 20s before finalizing request scopes. -// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer -// already closes the websocket gracefully. +// Skip Effect's default 20 s grace; WebSocket scope finalizers already close connections gracefully. const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; const layerResourceAttribution = ResourceAttribution.layer; const layerApplicationObservability = EventLoopMonitor.layer.pipe( @@ -209,8 +210,7 @@ const layerApplicationObservability = EventLoopMonitor.layer.pipe( Layer.provideMerge(layerResourceAttribution), ); -// One rotating store behind both logger views, so driver instances never race -// its rotation. +// Share rotation state so driver loggers cannot race. const layerProviderEventLoggers = Layer.effect( ProviderEventLoggers.ProviderEventLoggers, Effect.gen(function* () { @@ -255,8 +255,7 @@ const layerHostPowerMonitor = HostPowerMonitor.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); -// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here -// would open a second reader on the desktop telemetry fd. +// A fresh receiver layer would open a second reader on the desktop telemetry fd. const layerDesktopAppUpdate = DesktopAppUpdate.layer.pipe( Layer.provide(layerDesktopTelemetryReceiver), ); @@ -299,7 +298,19 @@ const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; return NodeHttpServer.layer( - () => rejectRequestsUntilServing(guardHttpResponseWriteErrors(NodeHttp.createServer())), + () => { + const server = rejectRequestsUntilServing( + guardHttpResponseWriteErrors(NodeHttp.createServer()), + ); + if (config.privateNetworkOnly) { + server.on("connection", (socket) => { + if (!acceptsPrivateNetworkConnection(socket)) { + socket.destroy(); + } + }); + } + return server; + }, { host: config.host ?? "127.0.0.1", port: config.port, @@ -314,7 +325,7 @@ const layerHttpServer = Layer.unwrap( }), ); -const layerPlatformServices = NodeServices.layer; +const layerPlatformServices = DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)); const layerPersistence = Layer.empty.pipe(Layer.provideMerge(SqlitePersistence.layerConfig)); @@ -332,9 +343,7 @@ const layerRepositoryIdentityResolver = Layer.effect( const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return yield* RepositoryIdentityResolver.make({ refine: Effect.fn(function* (identity: RepositoryIdentity) { - for (const kind of SourceControlBuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS.map( - (driver) => driver.kind, - )) { + for (const { kind } of BUILT_IN_SOURCE_CONTROL_DRIVER_LIST) { const provider = yield* registry.get(kind); if (provider.refineRepositoryIdentity === undefined) continue; const refined = yield* provider.refineRepositoryIdentity({ @@ -708,6 +717,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerOtlpTracesProxyRoute, ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, + GuestHttp.layer, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -721,10 +731,14 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide( PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), ), - // Both transports consume the same service instance, so caches single-flight across clients - // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. + // Share the service so HTTP/WS clients single-flight reads and invalidate each other's cached patches. Layer.provide(layerPullRequestService), - Layer.provide(ServerBrowser.layer.pipe(Layer.provide(DesktopBrowserChannel.layer))), + // The same activity instance RunExecutionService reports to, by layer memoization. + Layer.provide( + ServerBrowser.layer.pipe( + Layer.provide(Layer.merge(DesktopBrowserChannel.layer, PreviewAgentActivity.layer)), + ), + ), Layer.provide(PreviewBrowser.layer), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), @@ -850,11 +864,8 @@ const layerMakeServer = Layer.unwrap( ), Effect.asVoid, ); - // The previous server is already gone, so the trial owns cleanup - // immediately; the pending-state check keeps the tunnel for normal - // commit or rollback, while the launcher's explicit-stop marker allows - // it to be released. Other runtimes wait for activation so a failed - // standby cannot tear down the active runtime's tunnel. + // Trials own cleanup immediately after the old server exits, retaining tunnels for commit/rollback unless explicitly stopped. + // Other runtimes wait for activation so failed standbys cannot tear down active tunnels. const cleanupBeforeActivation = yield* pendingServiceUpdateExists; if (cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); @@ -904,8 +915,7 @@ const layerMakeServer = Layer.unwrap( ), ) : false; - // A failed read must not end this fiber before it registers - // recovery and starts consuming recovery requests. + // Recover reads so this fiber still registers and consumes recovery requests. const desiredCliLinkMode = wantsCliLink ? yield* CloudCliState.readCliDesiredLinkMode.pipe( Effect.catch((cause) => @@ -915,8 +925,7 @@ const layerMakeServer = Layer.unwrap( ), ) : null; - // A publish-only link must not expose the host, even if a managed - // config from an earlier link is still stored. + // Publish-only links must not expose the host through old managed configs. const startedConfirmed = desiredCliLinkMode === "publish_only" ? false @@ -966,8 +975,7 @@ const layerMakeServer = Layer.unwrap( }).pipe(Effect.as({ status: "unavailable" as const })), ), ); - // Spread those registrations so an auto-update wave does not hit - // the relay all at once. + // Jitter registrations so update waves do not hit the relay simultaneously. if (!startedConfirmed && desiredCliLinkMode !== "publish_only") { const jitter = yield* Random.nextIntBetween( 0, diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 2191b072b3239..9a4e71149a496 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -132,7 +132,6 @@ const logInSpanThrough = ( return { requests, spans }; }); -/** Logs one warning through the server logger and returns what its console printed. */ const printThroughConsole = (fields: object) => Effect.gen(function* () { const printed: Array = []; @@ -152,6 +151,21 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { + it.effect("exports the operator's service instance id instead of a generated one", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otelEnvironment: { + ...OtelEnvironment.none, + resourceAttributes: { "service.instance.id": "pod-7" }, + }, + }); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"service.instance.id","value":{"stringValue":"pod-7"}'); + }), + ); + it.effect("exports a resource that names whether the desktop app launched the server", () => Effect.gen(function* () { const { requests } = yield* logInSpanThrough({ @@ -247,7 +261,6 @@ describe("ServerLoggerLive", () => { it.effect("keeps the token out of traces when the browser fails to open", () => Effect.gen(function* () { - // A failed launch records its error, which names the target, on its span. const failingLaunch = maybeOpenBrowser(pairingUrl).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index af7ea3805f208..d138175029799 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -11,12 +11,7 @@ import * as OtlpLogger from "effect/observability/OtlpLogger"; import * as ServerConfig from "./config.ts"; -/** - * `Logger.consolePretty` prints message objects with `util.inspect`, which - * collapses a `Cause` passed as a field (`{ cause }`) to - * `{ failures: [ [Object] ] }`. Render those fields with `Cause.pretty`, the - * same text a `Cause` passed as its own log argument already gets. - */ +// consolePretty collapses Cause fields via util.inspect; render them with Cause.pretty like standalone arguments. const consolePretty = (): Logger.Logger => { const pretty = Logger.consolePretty(); return Logger.make((options) => @@ -35,8 +30,7 @@ function prettyCauseFields(value: unknown): unknown { if (prototype !== Object.prototype && prototype !== null) return value; // A message with its own redaction hook already decides how it prints. if (Redactable.isRedactable(value)) return value; - // Copy descriptors rather than entries so getters are never invoked and - // symbol keys survive the copy. + // Copy descriptors to preserve symbols without invoking getters. const descriptors = Object.getOwnPropertyDescriptors(value); let changed = false; for (const descriptor of Object.values(descriptors)) { @@ -64,13 +58,8 @@ export const layer = Effect.gen(function* () { resource: ServerConfig.otlpResource(config, hostName), }); - // `Logger.layer` writes the whole logger set rather than adding to it, so - // every logger the server wants has to be named in this one call. - // `Logger.tracerLogger` reaches a collector by attaching each message to the - // active span as a span event, which covers only messages logged inside a - // recorded span and files them under traces. The OTLP logger carries the same - // messages as log records stamped with their trace and span ids, so it is a - // superset: keeping both would export every in-span message twice. + // Logger.layer replaces the whole set; name every logger here. + // OTLP includes traced messages as log records, so also using tracerLogger would duplicate in-span exports. const layerLogger = Logger.layer( otlpLogger === undefined ? [consolePretty(), Logger.tracerLogger] diff --git a/apps/server/src/serverOwnership.ts b/apps/server/src/serverOwnership.ts index 50d0b2332c225..1959735425515 100644 --- a/apps/server/src/serverOwnership.ts +++ b/apps/server/src/serverOwnership.ts @@ -40,8 +40,7 @@ export class ServerAlreadyRunningError extends Schema.TaggedError (("errcode" in cause && cause.errcode === 5) || ("code" in cause && cause.code === "SQLITE_BUSY")); -/** - * Servers from before this lock only publish `server-runtime.json`. Treat - * their record as stale only when the process start time proves PID reuse. - */ +// Pre-lock servers publish only runtime state; require start-time proof of PID reuse before treating it as stale. const legacyOwnerIsLive = Effect.fn("legacyOwnerIsLive")(function* ( state: PersistedServerRuntimeState, ) { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 71ce7d9cb9a8a..b591e047f66cf 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -207,6 +207,7 @@ it.effect.each([ }), Layer.mock(ServiceLauncherClient.ServiceLauncherClient)({ managed: false, + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. prepareTrial: Effect.succeed(undefined), }), Layer.mock(GitVcsDriver.GitVcsDriver)({}), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 0b00654e344a2..63357f7d64ae4 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -345,19 +345,13 @@ export const resolveStartupBrowserTarget = Effect.gen(function* () { : yield* serverAuth.issueStartupPairingUrl(serverConfig.publicUrl?.toString() ?? baseTarget); }); -/** - * Prints the startup pairing URL to stdout, where `t3` users and the dev runner - * read it. The URL carries a one-time admin token, so it skips the logger: log - * records also land in the trace file and any configured OTLP collector. - */ +/** Prints the one-time admin pairing URL to stdout only; logger records reach trace files and collectors. */ export const announceStartupPairingUrl = (pairingUrl: string) => Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( - // Indented to match the annotation line the pretty logger printed before. Effect.andThen(Console.log(` pairingUrl: ${pairingUrl}`)), ); -// No URL here: the browser target carries the pairing token, which -// announceStartupPairingUrl has already printed to stdout. +// Pairing tokens belong only in announceStartupPairingUrl's stdout output. export const logBrowserAutoOpenUnavailable = Effect.logInfo("browser auto-open unavailable", { hint: "Open the pairing URL above in your browser.", }); @@ -381,9 +375,7 @@ export const maybeOpenBrowser = Effect.fn("serverRuntimeStartup.maybeOpenBrowser const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( - // Attributes on the span itself, not annotateSpans: an annotation follows - // every fiber forked inside the phase, such as the effect worker, for the - // life of the server. + // Use span attributes; annotations would follow long-lived forked fibers for the server's lifetime. Effect.withSpan(`server.startup.${phase}`, { attributes: { "startup.phase": phase } }), ); @@ -724,5 +716,3 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup export const layerWithOptions = (options?: StartupOptions) => Layer.effect(ServerRuntimeStartup, make(options)); - -const layer = layerWithOptions(); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 98e462f48d496..11971b7741ed6 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,7 @@ export const PersistedServerRuntimeState = Schema.Struct({ pid: Schema.Int, ownerId: Schema.optional(Schema.String), host: Schema.optional(Schema.String), + privateNetworkOnly: Schema.optional(Schema.Boolean), port: Schema.Int, origin: Schema.String, // Dev is single-origin: without a publicUrl, browsers pair through this URL, not `origin`. @@ -52,7 +53,10 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick< + ServerConfig.ServerConfig["Service"], + "host" | "privateNetworkOnly" | "devUrl" | "publicUrl" + >; readonly port: number; readonly serviceManaged?: boolean; }): Effect.Effect => @@ -60,6 +64,7 @@ export const makePersistedServerRuntimeState = (input: { version: 1, pid: process.pid, ...(input.config.host ? { host: input.config.host } : {}), + ...(input.config.privateNetworkOnly ? { privateNetworkOnly: true } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), @@ -113,10 +118,7 @@ export const clearPersistedServerRuntimeState = Effect.fn( ); }); -/** - * Signal 0 delivers nothing; it only reports whether the pid exists. EPERM - * means it exists but belongs to another user, which still counts as alive. - */ +/** Signal 0 only probes existence; EPERM still means alive. */ export const isProcessAlive = (pid: number): boolean => { if (!Number.isInteger(pid) || pid <= 0) return false; try { diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ac4e0d394284a..bd985cab203ac 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -410,8 +410,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("acpRegistry_shared"); - const results = yield* Effect.all( - ["First", "Second"].map((displayName) => + const results = yield* Effect.forEach( + ["First", "Second"], + (displayName) => serverSettings .updateProviderInstance({ operation: "create", @@ -423,7 +424,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }) .pipe(Effect.result), - ), { concurrency: "unbounded" }, ); @@ -695,7 +695,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - // A backend behind a custom model may refuse the built-in default (#12651). + // A backend behind a custom model may refuse the built-in default. it.effect("falls back to the thread default when it is a custom model on that instance", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1324,7 +1324,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.notInclude(yield* fs.readFileString(config.settingsPath), value); assert.notInclude(JSON.stringify(client), value); } - // Editing another field from the client's copy keeps the saved password. const clientConfig = client.providerInstances[opencode]?.config as Record; const edited = yield* service.updateProviderInstance({ operation: "upsert", @@ -1738,10 +1737,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { runOnWorktreeCreate: false, }; const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); - const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); - const scriptsJson = yield* Schema.encodeEffect( + const encodeModelSelection = Schema.encodeEffect(Schema.fromJsonString(ModelSelection)); + + const modelJson = yield* encodeModelSelection(model); + const encodeProjectScripts = Schema.encodeEffect( Schema.fromJsonString(Schema.Array(ProjectScript)), - )([script]); + ); + + const scriptsJson = yield* encodeProjectScripts([script]); for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ [legacyProject, modelJson, "worktree", 1, scriptsJson], [scriptedProject, null, null, 0, scriptsJson], diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 47bbefa509e05..3131c84d880e7 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -70,18 +70,13 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -/** - * Old settings files can hold both flags with conflicting values; an explicit false on either - * side wins so a user's disable is never silently undone. - */ +// Conflicting flags resolve to false to preserve user disables. const foldProviderInstanceEnabledFlags = (settings: ServerSettings): ServerSettings => { let changed = false; const providerInstances: Record = {}; for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { const config = instance.config; - // Only fold boolean flags: a malformed `enabled` (e.g. `"false"`) must - // stay in the blob so driver schema validation flags it instead of the - // fold silently repairing the config. + // Leave malformed flags for driver schema validation rather than silently repairing them. if ( config === null || typeof config !== "object" || @@ -197,10 +192,7 @@ export class ServerSettingsService extends Context.Service< readonly streamChanges: Stream.Stream; - /** - * Use this before reading a snapshot when changes between the snapshot and - * a lazily started stream must not be lost. - */ + /** Subscribe before reading a snapshot to retain changes before lazy stream startup. */ readonly subscribeChanges: Effect.Effect, never, Scope.Scope>; } >()("t3/serverSettings/ServerSettingsService") { @@ -264,8 +256,7 @@ const makeTest = Effect.fn("serverSettings.makeTest")(function* ( export const layerTest = (overrides: DeepPartial = {}) => Layer.effect(ServerSettingsService, makeTest(overrides)); -// Migrate saved token delivery without accepting it in settings writes or -// letting one retired value reset the rest of the environment's settings. +// Accept retired token delivery only on load so one old value cannot reset all settings. const PersistedResponseStreamingMode = Schema.Union([ ResponseStreamingMode, Schema.Literal("token"), @@ -496,9 +487,7 @@ interface LegacyProjectSettingsRow { readonly scripts: string; } -/** - * Marked with `projectSettingsFolded` so a later reset in the UI survives restarts. - */ +// projectSettingsFolded prevents restarts from undoing later UI resets. function foldLegacyProjectSettings( settings: ServerSettings, rows: ReadonlyArray, @@ -531,8 +520,7 @@ function foldLegacyProjectSettings( for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { set(projectId, "defaultAutoPull", value); } - // A stored null meant "reset to machine defaults", which is now plain - // inheritance; the project's own aggregate scripts must not resurface. + // A stored null resets to machine defaults, so the project's aggregate scripts must not resurface. const resetScripts = new Set(); for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { if (value === null) resetScripts.add(projectId); @@ -755,9 +743,7 @@ const make = Effect.gen(function* () { const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; - // Copied, not moved: the old entry is removed only after the new file is written. A token - // that cannot be read or copied leaves the retired keys in place, so the migration runs again - // on the next load instead of dropping it. + // Remove retired keys only after copying secrets and writing the new file; failures retry on next load. const legacySecretsCopied = yield* Effect.forEach( settingsFileTrusted ? migrateLegacySourceControlSettings(folded, legacySourceControl).secretMoves @@ -1091,9 +1077,7 @@ const make = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - // A symlinked settings file is rewritten in its destination's directory, - // which a watch on the link's directory never sees. The link is resolved - // again whenever it changes, so repointing it moves the watch along. + // Watch symlink targets' directories for rewrites; re-resolve link changes to follow repointing. const watchLinkTarget = Effect.gen(function* () { const linkTargetPath = yield* resolveSymlinkTarget(settingsPath).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -1121,8 +1105,7 @@ const make = Effect.gen(function* () { ), ); - // Editors emit multiple events per save (truncate, write, rename) and - // `fs.watch` can fire before the content has been flushed to disk. + // Saves emit multiple events; fs.watch may fire before content is flushed. const debouncedSettingsEvents = Stream.merge( watchFileChanges(settingsPath), linkTargetEvents, diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 9926159be2855..9f764b788efe2 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,9 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// The launcher supervises the server child for the boot service and must keep -// working across server versions, so it stays on Node built-ins with no Effect -// runtime: it is the one part of the executable that cannot depend on the -// rest of it being loadable. +// The launcher must survive unloadable server versions; use Node built-ins without Effect. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; @@ -39,8 +36,7 @@ const HANDOFF_DELAY_MS = 2_000; */ const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; -// Matches SERVER_EXIT_CODE_STATE_DIR_OWNED in contracts. The standalone -// launcher stays independent of the Effect-backed contracts package. +// Match contracts' SERVER_EXIT_CODE_STATE_DIR_OWNED without depending on Effect. const STATE_DIR_OWNED_EXIT_CODE = 78; type TerminalStatus = "committed" | "rolled-back" | "failed"; @@ -127,9 +123,7 @@ async function syncFile(filePath: string): Promise { } } -// Flushes a directory entry so a rename into it survives power loss. Windows -// has no directory fsync: the handle opens but sync fails with EPERM, and -// NTFS journals the rename on its own. +// Fsync makes renames durable; Windows returns EPERM and NTFS journals them itself. async function syncDirectory(directory: string): Promise { const handle = await NodeFSP.open(directory, "r"); try { @@ -147,14 +141,11 @@ function runCp(args: ReadonlyArray): Promise { }); } -/** - * Node's own clone flag is not used: on macOS `fs.copyFile` falls back to a full copy. - */ +// macOS fs.copyFile clone flags fall back to full copies. async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; - // `cp -c` replaces the destination with a new file, which would cut a - // hard-linked database off from its other links; copy into it instead. + // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. const hardLinked = platform === "darwin" && ((await NodeFSP.stat(destination).catch(() => undefined))?.nlink ?? 1) > 1; @@ -168,10 +159,7 @@ async function copyDatabaseFile(source: string, destination: string): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; @@ -236,10 +219,7 @@ const filesystemBackupSpaceProbe: BackupSpaceProbe = { canClone: backupCanClone, }; -/** - * Runs before the server is stopped so a full disk refuses the update instead of causing an - * outage and a cold restart. - */ +/** Check before stopping the server so full disks refuse updates without an outage. */ export async function databaseBackupBlocker( baseDir: string, dbPath: string, @@ -262,10 +242,7 @@ export async function databaseBackupBlocker( return `Not enough free disk space to back up the database before updating: ${gb(needed)} GB needed, ${gb(free)} GB free. Free up disk space and try again. The server was not stopped.`; } -/** - * A completed backup is never overwritten because a restarted launcher may be looking at - * database writes from an earlier attempt by the same trial. - */ +// Preserve completed backups; restarted trials may already have changed the live database. async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -295,7 +272,7 @@ const restoreMarkerPath = (baseDir: string, updateId: string) => const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) => pathExists(restoreMarkerPath(baseDir, pending.id)); -/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */ +// Mark before live writes so recovery cannot boot a partial restore. async function markDatabaseRestorePending(backupDir: string): Promise { const markerPath = NodePath.join(backupDir, RESTORE_MARKER); if (!(await pathExists(markerPath))) { @@ -309,11 +286,7 @@ async function markDatabaseRestorePending(backupDir: string): Promise { } } -/** - * The file a restore writes for `path`: where its symlinks lead, even when the - * last target is missing, as after a crash between `cp -c` removing a file - * and cloning over it. - */ +// Resolve symlink targets even if a crashed cp -c left the final target missing. async function restoreTarget(path: string): Promise { let current = path; for (let hops = 0; hops < 40; hops++) { @@ -328,8 +301,7 @@ async function restoreTarget(path: string): Promise { throw cause; }); if (link === undefined) return current; - // Joined without normalizing: a `..` in the link must follow directory links - // the way the filesystem does, not cancel a path segment. + // Do not normalize .. before following directory symlinks; match filesystem resolution. current = NodePath.isAbsolute(link) ? link : `${await NodeFSP.realpath(NodePath.dirname(current))}${NodePath.sep}${link}`; @@ -350,8 +322,7 @@ async function restoreDatabaseBackup( const livePath = `${pending.dbPath}${suffix}`; const source = databaseBackupFile(backupDir, suffix); if (await pathExists(source)) { - // A clone replaces its destination, so restore into the file a symlinked - // database resolves to and keep the link itself. + // Restore into the symlink target; cloning would replace the link. const target = await restoreTarget(livePath); await copyDatabaseFile(source, target); await syncFile(target); @@ -526,9 +497,7 @@ export class Launcher { } async stop(signal: NodeJS.Signals): Promise { - // This must happen synchronously at signal receipt. A queued update - // transition may already be terminating the active child, and that child - // needs to see the marker in its shutdown finalizer. + // Write synchronously on signal receipt; a terminating child needs the marker in its finalizer. try { NodeFS.writeFileSync(stopMarkerPath(this.#baseDir), "", { mode: 0o600 }); } catch {} @@ -539,9 +508,7 @@ export class Launcher { this.#stopRequested = true; this.#clearTimer(); this.#enqueue(async () => { - // Let an update transition already in progress start its replacement - // before this queued stop tears it down. That replacement owns the - // pre-activation tunnel cleanup path and observes the marker above. + // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. this.#stopping = true; const child = this.#child?.process; this.#child = null; @@ -558,13 +525,7 @@ export class Launcher { } async #recover(): Promise { - // A fresh launcher means servers are running again: any stop marker from - // a previous explicit stop is stale and must not make a future update - // handoff release its tunnel. A restart deferred by `t3 update` is done - // no matter who restarted the service, but only once this launcher is - // the version the marker waits for: a launcher that came up between the - // CLI writing the marker and writing the new state still runs the old - // version, and the marker has to outlive it. + // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); @@ -700,8 +661,7 @@ export class Launcher { await reject("The requested target runtime is missing or incomplete."); return; } - // A check that cannot run refuses the update too: the alternative is - // stopping the server without knowing whether the backup fits. + // Failed checks must refuse updates rather than stop the server with unknown backup capacity. const backupBlocker = await databaseBackupBlocker( this.#baseDir, message.dbPath, @@ -812,11 +772,7 @@ export class Launcher { throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); } - /** - * Exiting would make systemd or launchd start another refused server, so stay - * idle until stopped or restarted. A pending update fails without restoring - * its backup: the other owner may have written to the database since. - */ + // Stay idle to avoid supervisor restart loops; never restore backups over another owner's possible writes. async #idleWhileStateDirOwned(): Promise { this.#clearTimer(); const pending = this.#state.update; diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 42cce7ad85c85..c82f5f3cad077 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,582 +1,131 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, VcsProcessSpawnError } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { providerAuth } from "@t3tools/source-control-core/server/discovery"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { + fakeDiscovery, + fakeSourceControlProvider, +} from "@t3tools/source-control-testing/fakeSourceControlDriver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; -import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; -import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; -import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; -const layerSourceControlProviderRegistryTest = (input: { - readonly bitbucket: Partial; - readonly process: Partial; -}) => - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-registry-test-", - }).pipe(Layer.provide(NodeServices.layer)), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - Layer.mock(GitHubRest.GitHubRest)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)({}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), - Layer.mock(VcsProcess.VcsProcess)(input.process), - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(VcsProcess.VcsProcess)(input.process), - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), - ), - ), - ), - ), - ), +const kind = SourceControlProviderKind.make; + +// Serves both the host's runs and the server's own VCS probes, whose inputs share these fields. +const run = ( + input: Pick< + SourceControlHost.SourceControlProcessInput, + "operation" | "command" | "args" | "cwd" + >, +): Effect.Effect => { + if (input.args[0] === "--version") { + return input.command === "missing-cli" + ? Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ) + : Effect.succeed(TestSourceControlHost.processOutput(`${input.command} version test\n`)); + } + return Effect.succeed( + TestSourceControlHost.processOutput(`signed in as ${input.command}-user\n`), ); +}; -const processOutput = ( - stdout: string, - options?: { - readonly stderr?: string; - readonly exitCode?: ChildProcessSpawner.ExitCode; - }, -): VcsProcess.VcsProcessOutput => ({ - exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), - stdout, - stderr: options?.stderr ?? "", - stdoutTruncated: false, - stderrTruncated: false, +const cliDiscovery = (name: string) => ({ + type: "cli" as const, + kind: kind(name), + label: name, + installHint: `Install ${name}.`, + executable: name === "beta" ? "missing-cli" : `${name}-cli`, + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: ({ stdout }: { readonly stdout: string }) => + providerAuth({ status: "authenticated", account: /as (\S+)/.exec(stdout)?.[1] }), }); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -it.effect("probes provider authentication without exposing token details", () => { - const processMock = { - run: (input: VcsProcess.VcsProcessInput) => { - if (input.args[0] === "--version") { - return Effect.succeed(processOutput(`${input.command} version test\n`)); - } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { - return Effect.succeed( - processOutput( - encodeJson({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "octocat", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), - ); - } - if (input.command === "glab" && input.args.join(" ") === "auth status") { - return Effect.succeed( - processOutput(`gitlab.com -Logged in to gitlab.com as gitlab-user -`), - ); - } - if (input.command === "tea" && input.args[0] === "login") { - return Effect.succeed( - processOutput( - encodeJson([ - { - name: "forgejo", - url: "https://forgejo.example.com", - ssh_host: "forgejo.example.com", - user: "forgejo-user", - valid: "true", - default: "true", - }, - ]), - ), - ); - } - if ( - input.command === "az" && - input.args.join(" ") === "account show --query user.name -o tsv" - ) { - return Effect.succeed(processOutput("azure-user@example.com\n")); - } - if (input.command === "cafe" && input.args.slice(-3).join(" ") === "auth status --json") { - return Effect.succeed( - processOutput( - encodeJson({ schemaVersion: 1, data: { host: "git.cafe", username: "cafe-user" } }), - ), - ); - } - return Effect.fail( - new VcsProcessSpawnError({ - operation: input.operation, - command: input.command, - cwd: input.cwd, - cause: new Error(`${input.command} not found`), - }), - ); - }, - } satisfies Partial; - const layerTest = SourceControlDiscovery.layer.pipe( - Layer.provide( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-auth-discovery-", - }), - ), - Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)), - Layer.provide( - layerSourceControlProviderRegistryTest({ - process: processMock, - bitbucket: { - probeAuth: Effect.succeed({ - status: "authenticated", - account: Option.some("bitbucket-user"), - host: Option.some("bitbucket.org"), - detail: Option.none(), - }), - }, +const layerDiscovery = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + SourceControlProviderRegistry.makeWithProviders( + [ + cliDiscovery("alpha"), + cliDiscovery("beta"), + fakeDiscovery("gamma", { + probeAuth: Effect.succeed(providerAuth({ status: "authenticated", account: "api-user" })), }), - ), - Layer.provideMerge(NodeServices.layer), - ); + ].map((discovery) => ({ + kind: discovery.kind, + provider: fakeSourceControlProvider(discovery.kind), + discovery, + })), + ), +).pipe( + Layer.provideMerge(TestSourceControlHost.layer({ process: { run } })), + Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), + Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), + Layer.provideMerge(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provideMerge(NodeServices.layer), +); - return Effect.gen(function* () { +it.effect("reports every hosting integration in registry order with its sign-in", () => + Effect.gen(function* () { const discovery = yield* SourceControlDiscovery.SourceControlDiscovery; const result = yield* discovery.discover; assert.deepStrictEqual( result.sourceControlProviders.map((item) => ({ kind: item.kind, + status: item.status, + version: item.version, auth: item.auth.status, account: item.auth.account, - detail: item.auth.detail, })), [ { - kind: SourceControlProviderKind.make("github"), + kind: kind("alpha"), + status: "available", + version: Option.some("alpha-cli version test"), auth: "authenticated", - account: Option.some("octocat"), - detail: Option.none(), + account: Option.some("alpha-cli-user"), }, { - kind: SourceControlProviderKind.make("gitlab"), - auth: "authenticated", - account: Option.some("gitlab-user"), - detail: Option.none(), + kind: kind("beta"), + status: "missing", + version: Option.none(), + auth: "unknown", + account: Option.none(), }, { - kind: SourceControlProviderKind.make("azure-devops"), + kind: kind("gamma"), + status: "available", + version: Option.none(), auth: "authenticated", - account: Option.some("azure-user@example.com"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("bitbucket"), - auth: "authenticated", - account: Option.some("bitbucket-user"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("forgejo"), - auth: "authenticated", - account: Option.some("forgejo-user"), - detail: Option.none(), - }, - { - kind: SourceControlProviderKind.make("gitcafe"), - auth: "authenticated", - account: Option.some("cafe-user"), - detail: Option.none(), + account: Option.some("api-user"), }, ], ); - }).pipe(Effect.provide(layerTest)); -}); - -it.effect( - "checks out fj pull refs and preserves existing branches and dirty files until forced", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const git = yield* VcsProcess.VcsProcess; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fj-checkout-" }); - const source = path.join(root, "source"); - const cwd = path.join(root, "checkout"); - yield* fs.makeDirectory(source); - for (const args of [ - ["init", "-b", "main"], - ["config", "user.name", "Test"], - ["config", "user.email", "test@example.com"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - yield* fs.writeFileString(path.join(source, "base.txt"), "base\n"); - for (const args of [ - ["add", "base.txt"], - ["commit", "-m", "base"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - const base = (yield* git.run({ - operation: "test.setup", - command: "git", - cwd: source, - args: ["rev-parse", "HEAD"], - })).stdout.trim(); - yield* git.run({ - operation: "test.setup", - command: "git", - cwd: root, - args: ["clone", source, cwd], - }); - yield* fs.writeFileString(path.join(source, "feature.txt"), "pull request change\n"); - for (const args of [ - ["add", "feature.txt"], - ["commit", "-m", "feature"], - ["update-ref", "refs/pull/42/head", "HEAD"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); - const head = (yield* git.run({ - operation: "test.setup", - command: "git", - cwd: source, - args: ["rev-parse", "HEAD"], - })).stdout.trim(); - const fetched: string[] = []; - const host = yield* SourceControlHost.SourceControlHost; - const provider = yield* ForgejoSourceControlProvider.make.pipe( - Effect.provideService( - SourceControlHost.SourceControlHost, - SourceControlHost.SourceControlHost.of({ - settings: host.settings, - git: host.git, - process: { - run: (input) => { - if (input.args[0] !== "fetch") return git.run(input); - const url = input.args[2]; - assert.isDefined(url); - fetched.push(url!); - return git.run({ - ...input, - args: input.args.map((arg) => - arg === "git@forgejo.test:reviewer/project.git" ? source : arg, - ), - }); - }, - }, - }), - ), - Effect.provide( - Layer.mock(ForgejoCli.ForgejoCli)({ - resolveRepository: () => - Effect.succeed({ - command: "fj", - login: "work", - repository: "reviewer/project", - baseUrl: "https://forgejo.test", - }), - api: (input) => { - assert.include( - ["repos/reviewer/project", "repos/reviewer/project/pulls/42"], - input.path, - ); - return Effect.succeed( - processOutput( - encodeJson( - input.path.endsWith("/pulls/42") - ? { - number: 42, - title: "Checkout", - html_url: "https://forgejo.test/reviewer/project/pulls/42", - state: "open", - merged: false, - base: { ref: "main", sha: base, repo: null }, - head: { ref: "feature", sha: head, repo: null }, - } - : { - full_name: "reviewer/project", - clone_url: source, - ssh_url: "git@forgejo.test:reviewer/project.git", - default_branch: "main", - }, - ), - ), - ); - }, - }), - ), - ); - yield* provider.checkoutChangeRequest({ - cwd, - reference: "https://forgejo.test/reviewer/project/pulls/42", - }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["branch", "--show-current"], - })).stdout.trim(), - "pulls/42", - ); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - head, - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "feature.txt")), - "pull request change\n", - ); - for (const args of [ - ["checkout", "main"], - ["branch", "-f", "pulls/42", "main"], - ]) - yield* git.run({ operation: "test.setup", command: "git", cwd, args }); - yield* fs.writeFileString(path.join(cwd, "base.txt"), "uncommitted work\n"); - yield* provider.checkoutChangeRequest({ cwd, reference: "42" }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - base, - ); - assert.strictEqual(yield* fs.exists(path.join(cwd, "feature.txt")), false); - yield* provider.checkoutChangeRequest({ - cwd, - reference: "42", - force: true, - context: { - provider: { - kind: SourceControlProviderKind.make("forgejo"), - name: "Forgejo", - baseUrl: "https://forgejo.test", - }, - remoteName: "origin", - remoteUrl: "git@forgejo.test:maria/project.git", - }, - }); - assert.strictEqual( - (yield* git.run({ - operation: "test.verify", - command: "git", - cwd, - args: ["rev-parse", "HEAD"], - })).stdout.trim(), - head, - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "base.txt")), - "uncommitted work\n", - ); - assert.strictEqual( - yield* fs.readFileString(path.join(cwd, "feature.txt")), - "pull request change\n", - ); - assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); - }).pipe( - Effect.scoped, - Effect.provide( - ServerSourceControlHost.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()), - Layer.provideMerge(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provideMerge(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), -); - -it("keeps case-distinct mounted Forgejo instances separate when selecting a login", () => { - // Instance mount paths are case-sensitive, so `/Forge` and `/forge` are different servers - // and must never fold together when a login is selected. - const logins = ["Forge", "forge"].map((path) => ({ - name: path, - url: `https://forge.example:3000/${path}`, - user: "alice", - default: "false", - ssh_host: path === "Forge" ? "ssh.example:2222" : "ssh.example:3333", - })); - const select = (remoteUrl: string) => - ForgejoCli.matchForgejoLogin(logins, ForgejoCli.parseForgejoRemote(remoteUrl)!)?.name; - assert.strictEqual(select("https://forge.example:3000/Forge/Owner/Repo.git"), "Forge"); - assert.strictEqual(select("https://forge.example:3000/forge/Owner/Repo.git"), "forge"); - assert.strictEqual(select("ssh://git@ssh.example:3333/Owner/Repo.git"), "forge"); - assert.isUndefined(select("https://forge.example:4000/Forge/Owner/Repo.git")); -}); - -it.effect.each(["matching", "wrong-path", "wrong-port", "ambiguous", "unavailable"] as const)( - "discovers advertised Forgejo SSH remotes: %s", - (scenario) => { - let repositoryLookups = 0; - return Effect.gen(function* () { - const cli = yield* ForgejoCli.make; - const discovery = yield* ForgejoSourceControlProvider.makeDiscovery.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - const context = { - provider: { - kind: SourceControlProviderKind.make("unknown"), - name: "git.rudd-agama.ts.net", - baseUrl: "https://git.rudd-agama.ts.net", - }, - remoteName: "origin", - remoteUrl: "ssh://git@git.rudd-agama.ts.net/maria/project.git", - }; - assert.strictEqual(discovery.type, "managed-cli"); - if (discovery.type !== "managed-cli") return; - const provider = yield* discovery.refineUnknownRemote({ cwd: "/repo", context }); - if (scenario !== "matching") { - assert.isNull(provider); - return; - } - assert.deepStrictEqual(provider, { - kind: "forgejo", - name: "Forgejo / Gitea", - baseUrl: "https://git.fryuni.dev", - }); - if (!provider) return; - const resolved = yield* cli.resolveRepository({ - cwd: "/repo", - context: { ...context, provider }, - }); - assert.strictEqual(resolved.baseUrl, "https://git.fryuni.dev"); - assert.strictEqual(resolved.repository, "maria/project"); - const sourceControl = yield* ForgejoSourceControlProvider.make.pipe( - Effect.provideService(ForgejoCli.ForgejoCli, cli), - ); - assert.deepStrictEqual( - yield* sourceControl.listChangeRequests({ - cwd: "/repo", - context: { ...context, provider }, - headSelector: "feature", - state: "open", - }), - [], - ); - // Discovery and subsequent PR operations share the clone URL probe. - assert.strictEqual(repositoryLookups, 2); - }).pipe( - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: () => Effect.succeed(true), - readFileString: () => - Effect.succeed( - encodeJson({ - hosts: { - "git.fryuni.dev": { type: "Application", token: "test-token" }, - "other.test": { type: "Application", token: "other-token" }, - }, - aliases: {}, - }), - ), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - assert.strictEqual(request.method, "GET"); - if (request.url.includes("/pulls?")) { - assert.isTrue( - request.url.startsWith("https://git.fryuni.dev/api/v1/repos/maria/project/pulls?"), - ); - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("[]"))); - } - repositoryLookups++; - assert.isTrue(request.url.endsWith("/api/v1/repos/maria/project")); - const sshUrl = - scenario === "wrong-path" - ? "git@git.rudd-agama.ts.net:maria/other.git" - : scenario === "wrong-port" - ? "ssh://git@git.rudd-agama.ts.net:2222/maria/project.git" - : "git@git.rudd-agama.ts.net:maria/project.git"; - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - encodeJson({ - ssh_url: - request.url.startsWith("https://git.fryuni.dev/") || scenario === "ambiguous" - ? sshUrl - : "git@other.test:maria/project.git", - }), - { status: scenario === "unavailable" ? 404 : 200 }, - ), - ), - ); - }), - ), - Effect.provide( - TestSourceControlHost.layer({ - process: { - run: (input) => - input.command === "fj" - ? Effect.succeed(processOutput("")) - : Effect.succeed(processOutput("[]")), - }, - }), - ), + assert.deepStrictEqual( + result.versionControlSystems.map((item) => [item.kind, item.status]), + [ + ["git", "available"], + ["jj", "available"], + ], ); - }, + }).pipe(Effect.provide(SourceControlDiscovery.layer.pipe(Layer.provide(layerDiscovery)))), ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index f56985fefd639..b6c8239f1705a 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -64,7 +64,7 @@ export class SourceControlDiscovery extends Context.Service< } >()("t3/sourceControl/SourceControlDiscovery") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 20f9cd0d033e1..a3f52b49cdfd1 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -9,54 +9,78 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; -import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { HttpClient } from "effect/http"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { ChildProcessSpawner } from "effect/process"; import { DEFAULT_SERVER_SETTINGS, VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import type { + SourceControlCliDiscoverySpec, + SourceControlManagedCliDiscoverySpec, + SourceControlProviderDiscoverySpec, +} from "@t3tools/source-control-core/server/discovery"; +import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { fakeSourceControlProvider } from "@t3tools/source-control-testing/fakeSourceControlDriver"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubRequestPolicy from "@t3tools/source-control-github/server/GitHubRequestPolicy"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; -import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const kind = SourceControlProviderKind.make; + +class NotAnswered extends Schema.TaggedError()("NotAnswered", {}) {} + +const linkSubject = (host: string) => () => + Effect.succeed({ title: `${host} subject`, body: null }); + +/** Only GitHub and GitLab read linked subjects, as in production. */ +const PROVIDERS = [ + fakeSourceControlProvider("github", { resolveLink: linkSubject("github") }), + fakeSourceControlProvider("gitlab", { resolveLink: linkSubject("gitlab") }), + fakeSourceControlProvider("forgejo"), + fakeSourceControlProvider("bitbucket"), + fakeSourceControlProvider("azure-devops"), +]; + +/** A host CLI that claims every server it is signed in to, as `glab` does. */ +const claimingCli = (claims: string): SourceControlCliDiscoverySpec => ({ + type: "cli", + kind: kind(claims), + label: claims, + installHint: `Install ${claims}.`, + executable: `${claims}-cli`, + versionArgs: ["--version"], + authArgs: ["auth", "status"], + remoteRefinementArgs: ({ provider }) => ["auth", "status", "--hostname", provider.name], + parseAuth: () => ({ + status: "unknown", + account: Option.none(), + host: Option.none(), + detail: Option.none(), + }), + refineUnknownRemote: ({ context, auth }) => + auth.stdout.includes(`Logged in to ${context.provider.name}`) + ? { kind: kind(claims), name: `${claims} Self-Hosted`, baseUrl: context.provider.baseUrl } + : null, +}); -const processOutput = ( - stdout: string, - options?: { - readonly stderr?: string; - readonly exitCode?: ChildProcessSpawner.ExitCode; - }, -): VcsProcess.VcsProcessOutput => ({ - exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), - stdout, - stderr: options?.stderr ?? "", - stdoutTruncated: false, - stderrTruncated: false, +const managedCli = ( + claims: string, + refine: SourceControlManagedCliDiscoverySpec["refineUnknownRemote"], +): SourceControlManagedCliDiscoverySpec => ({ + type: "managed-cli", + kind: kind(claims), + label: claims, + installHint: `Install ${claims}.`, + probe: () => Effect.die("unused"), + refineUnknownRemote: refine, }); function makeRegistry(input: { @@ -64,12 +88,9 @@ function makeRegistry(input: { readonly name: string; readonly url: string; }>; - readonly process?: Partial; - readonly githubApi?: Partial; - readonly gitlab?: Partial; - readonly forgejo?: Partial; + readonly discovery?: ReadonlyArray; + readonly run?: SourceControlHost.SourceControlHost["Service"]["process"]["run"]; readonly settings?: Partial; - readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; }) { const driver = { listRemotes: () => @@ -87,78 +108,47 @@ function makeRegistry(input: { }), } satisfies Partial; - const layerRegistry = Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ - get: () => Effect.succeed(driver as unknown as VcsDriver.VcsDriver["Service"]), - resolve: - input.resolve ?? - (() => - Effect.succeed({ - kind: "git", - repository: { - kind: "git", - rootPath: "/repo", - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - }, - driver: driver as unknown as VcsDriver.VcsDriver["Service"], - })), - }); - - const layerProcess = Layer.mock(VcsProcess.VcsProcess)({ - run: () => Effect.succeed(processOutput("")), - ...input.process, - }); - - return SourceControlProviderRegistry.make.pipe( + const discovery = input.discovery ?? [claimingCli("gitlab")]; + return SourceControlProviderRegistry.makeWithProviders( + PROVIDERS.map((provider) => ({ + kind: provider.kind, + provider, + discovery: discovery.find((spec) => spec.kind === provider.kind) ?? { + type: "api", + kind: provider.kind, + label: provider.kind, + installHint: "unused", + probeAuth: Effect.die("unused"), + }, + })), + ).pipe( Effect.provide( Layer.mergeAll( - NodeServices.layer, - layerRegistry, - layerProcess, - ServerSourceControlHost.layer.pipe( - Layer.provide( - Layer.mergeAll( - layerProcess, - layerRegistry, - ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - ), - ), - ), - Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), - Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({}), - Layer.mock(BitbucketApi.BitbucketApi)({}), - Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({}), + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriver.VcsDriver["Service"], + }), + }), + TestSourceControlHost.layer({ + process: { + run: input.run ?? (() => Effect.succeed(TestSourceControlHost.processOutput(""))), + }, + }), input.settings ? Layer.mock(ServerSettings.ServerSettingsService)(input.settings) : ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({}), - // The GitHub provider's GraphQL reads go through these; no test here makes one. - Layer.mock(GitHubRequestPolicy.GitHubRequestPolicy)({}), - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die(new Error("Unexpected GitHub GraphQL read"))), - ), - Layer.mock(GitHubRest.GitHubRest)({ - // No GitHub credential unless a test supplies one, so custom hosts stay unclaimed. - credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - ...input.githubApi, - }), - Layer.mock(GitVcsDriver.GitVcsDriver)({}), - Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({}), - Layer.mock(ForgejoCli.ForgejoCli)({ - listLogins: () => Effect.succeed([]), - ...input.forgejo, - }), - Layer.mock(GitCafeApi.GitCafeApi)({}), - Layer.mock(GitCafeCredentials.GitCafeCredentials)({ - cliEnv: () => Effect.succeed({}), - }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), @@ -167,30 +157,41 @@ function makeRegistry(input: { ); } -it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () => +const signedIn = (host: string) => () => + Effect.succeed(TestSourceControlHost.processOutput(` ✓ Logged in to ${host} as user\n`)); + +it.effect("routes an unknown remote to the integration that claims its server", () => Effect.gen(function* () { + const asked: Array = []; const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput( - `gitlab.com - x gitlab.com: API call failed: 401 Unauthorized - ! No token found -self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user - ✓ Token found: ****** -`, - { exitCode: ChildProcessSpawner.ExitCode(1) }, - ), - ), + remotes: [{ name: "origin", url: "git@self-hosted.example.test:group/project.git" }], + run: (input) => { + if (input.command === "git") return Effect.succeed(TestSourceControlHost.processOutput("")); + asked.push([input.command, ...input.args].join(" ")); + return signedIn("self-hosted.example.test")(); }, }); - const provider = yield* registry.resolve({ cwd: "/repo" }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); - assert.strictEqual(provider.kind, "gitlab"); + assert.strictEqual(handle.provider.kind, "gitlab"); + assert.deepStrictEqual(handle.context?.provider, { + kind: kind("gitlab"), + name: "gitlab Self-Hosted", + baseUrl: "https://self-hosted.example.test", + }); + assert.deepStrictEqual(asked, ["gitlab-cli auth status --hostname self-hosted.example.test"]); + }), +); + +it.effect("leaves a remote no integration claims unknown, with its server's address", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], + }); + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + assert.strictEqual(handle.provider.kind, kind("unknown")); + assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); }), ); @@ -198,21 +199,14 @@ it.effect("refines the caller-selected remote instead of choosing another config Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput(`self-hosted.example.test - ✓ Logged in to self-hosted.example.test as gitlab-user -`), - ), - }, + run: signedIn("self-hosted.example.test"), }); const handle = yield* registry.resolveHandle({ cwd: "/repo", context: { provider: { - kind: SourceControlProviderKind.make("unknown"), + kind: kind("unknown"), name: "self-hosted.example.test", baseUrl: "https://self-hosted.example.test", }, @@ -226,29 +220,6 @@ it.effect("refines the caller-selected remote instead of choosing another config }), ); -it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => - Effect.gen(function* () { - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }], - process: { - run: () => - Effect.succeed( - processOutput( - `self-hosted.example.test:8443 - ✓ Logged in to self-hosted.example.test:8443 as gitlab-user - ✓ Token found: ****** -`, - ), - ), - }, - }); - - const provider = yield* registry.resolve({ cwd: "/repo" }); - - assert.strictEqual(provider.kind, "gitlab"); - }), -); - it.effect("falls back to a non-origin remote when origin is not configured", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -267,27 +238,24 @@ it.effect("shares cached refinement while keeping requested web authorities sepa let probes = 0; const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: remoteUrl }], - // Other hosting CLIs are not installed, so the Forgejo answer settles the verdict. - process: { run: ({ command, cwd }) => Effect.fail(notFound(command, cwd)) }, - forgejo: { - listLogins: () => + discovery: [ + managedCli("forgejo", ({ context }) => Effect.sync(() => { probes++; - return [3000, 4000].map((port) => ({ - name: String(port), - url: `https://forge.example:${port}`, - ssh_host: "ssh.example:2222", - user: "alice", - default: "false", - })); + return { + kind: kind("forgejo"), + name: "Forgejo", + baseUrl: `https://${context.requestedHost}`, + }; }), - }, + ), + ], }); const resolve = (port: number) => registry.resolveHandle({ cwd: "/repo", context: { - provider: detectSourceControlProviderFromRemoteUrl(remoteUrl)!, + provider: { kind: kind("unknown"), name: "ssh.example", baseUrl: "https://ssh.example" }, remoteName: "origin", remoteUrl, requestedHost: `forge.example:${port}`, @@ -301,9 +269,6 @@ it.effect("shares cached refinement while keeping requested web authorities sepa "https://forge.example:4000", ); assert.strictEqual(probes, 2); - yield* TestClock.adjust("6 seconds"); - yield* resolve(3000); - assert.strictEqual(probes, 2); yield* TestClock.adjust("5 minutes"); yield* resolve(3000); assert.strictEqual(probes, 3); @@ -311,44 +276,22 @@ it.effect("shares cached refinement while keeping requested web authorities sepa ); it.effect( - "routes linked subjects by URL independently of the checkout and skips unsupported links", + "routes linked subjects to the host that serves them, independently of the checkout", () => Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }], - githubApi: { - rest: () => - Effect.succeed({ - status: 200, - headers: {}, - body: JSON.stringify({ title: "GitHub issue", body: null }), - truncated: false, - invalidUtf8: false, - }), - }, - gitlab: { - execute: () => - Effect.succeed( - processOutput(JSON.stringify({ title: "GitLab MR", description: "Nested project" })), - ), - }, }); - for (const [url, expected] of [ - ["https://github.com/team/project/issues/1", { title: "GitHub issue", body: null }], - [ - "https://gitlab.com/team/sub/project/-/merge_requests/2", - { title: "GitLab MR", body: "Nested project" }, - ], + for (const [url, host] of [ + ["https://github.com/team/project/issues/1", "github"], + ["https://gitlab.com/team/sub/project/-/merge_requests/2", "gitlab"], ] as const) { const lookup = registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }); assert.ok(lookup); - assert.deepStrictEqual(yield* lookup, expected); + assert.deepStrictEqual(yield* lookup, { title: `${host} subject`, body: null }); } for (const url of [ "https://example.test/team/project/issues/1", - "https://github.attacker.test/team/project/issues/1", - "https://gitlab.attacker.test/team/project/-/issues/1", - "https://github.com/team/project", "https://codeberg.org/team/project/issues/1", "https://bitbucket.org/team/project/pull-requests/1", "https://dev.azure.com/org/project/_git/repo/pullrequest/1", @@ -358,70 +301,38 @@ it.effect( assert.strictEqual( registry.resolveLink({ cwd: "/unrelated", url: new URL(url) }), undefined, + url, ); } }).pipe(Effect.scoped), ); -it.effect.each([ - { name: "a credential", credential: "found", expected: "github" }, - { name: "a host turned off in Settings", credential: "disabled", expected: "github" }, - { name: "no credential", credential: "missing", expected: "unknown" }, -] as const)("resolves custom-host remotes with $name", (scenario) => - Effect.gen(function* () { - const hosts: Array = []; - const commands: Array = []; - const registry = yield* makeRegistry({ - remotes: [{ name: "origin", url: "git@code.example.test:team/project.git" }], - process: { - run: ({ command }) => { - commands.push(command); - return Effect.succeed(processOutput("")); - }, - }, - githubApi: { - credential: (host) => { - hosts.push(host); - return scenario.credential === "found" - ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) - : scenario.credential === "disabled" - ? Effect.fail(new GitHubCredentials.GitHubHostDisabledError({ host })) - : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); - }, - }, - }); - const handle = yield* registry.resolveHandle({ cwd: "/repo" }); - assert.strictEqual(handle.provider.kind, SourceControlProviderKind.make(scenario.expected)); - assert.strictEqual(handle.context?.provider.baseUrl, "https://code.example.test"); - assert.deepStrictEqual(hosts, ["code.example.test"]); - assert.notInclude(commands, "gh"); - }), -); - -it.effect("skips GitHub discovery for the identity resolver's empty base URL", () => +it.effect("does not refine a context whose server address is empty", () => Effect.gen(function* () { - const hosts: string[] = []; + let asked = 0; const remoteUrl = "git@githubenterprise.dev.example.com:team/workspace.git"; const registry = yield* makeRegistry({ remotes: [{ name: "origin", url: remoteUrl }], - githubApi: { - credential: (host) => { - hosts.push(host); - return Effect.succeed({ - token: Redacted.make("token"), - fingerprint: `${host}:fingerprint`, - }); - }, - }, + discovery: [ + managedCli("github", ({ context }) => { + asked += 1; + return Effect.succeed( + context.provider.baseUrl === "" + ? null + : { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl }, + ); + }), + ], }); const context = { - provider: { kind: SourceControlProviderKind.make("unknown"), name: "Unknown", baseUrl: "" }, + provider: { kind: kind("unknown"), name: "Unknown", baseUrl: "" }, remoteName: "origin", remoteUrl, }; - const unresolved = yield* registry.resolveHandle({ cwd: "/repo", context }); - assert.deepStrictEqual(unresolved.context, context); - assert.deepStrictEqual(hosts, []); + assert.deepStrictEqual( + (yield* registry.resolveHandle({ cwd: "/repo", context })).context, + context, + ); const resolved = yield* registry.resolveHandle({ cwd: "/repo" }); assert.strictEqual(resolved.provider.kind, "github"); @@ -429,7 +340,7 @@ it.effect("skips GitHub discovery for the identity resolver's empty base URL", ( resolved.context?.provider.baseUrl, "https://githubenterprise.dev.example.com", ); - assert.deepStrictEqual(hosts, ["githubenterprise.dev.example.com"]); + assert.strictEqual(asked, 2); }), ); @@ -449,7 +360,7 @@ const resolveUnknown = ( cwd: `/${repository}`, context: { provider: { - kind: SourceControlProviderKind.make("unknown"), + kind: kind("unknown"), name: "git.example.internal", baseUrl: "https://git.example.internal", }, @@ -464,25 +375,18 @@ it.effect("asks once for every checkout of a server no integration claims", () = const release = yield* Deferred.make(); const registry = yield* makeRegistry({ remotes: [], - process: { - run: ({ command, args, cwd }) => { - if (command === "git") return Effect.fail(notFound(command, cwd)); - asked.push([command, ...args].join(" ")); - return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd)))); - }, - }, - githubApi: { - credential: (host) => { - asked.push(`credential ${host}`); - return Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })); - }, - }, - forgejo: { - listLogins: ({ command = "tea", cwd }) => { - asked.push(command); - const cause = notFound(command, cwd); - return Effect.fail(new ForgejoCli.ForgejoCliError({ command, cwd, detail: "", cause })); - }, + discovery: [ + claimingCli("gitlab"), + managedCli("github", ({ context }) => { + asked.push(`managed ${context.provider.name}`); + return Effect.succeed(null); + }), + ], + run: ({ command, args, cwd }) => { + // The configured-remote read is not an integration's question. + if (command === "git") return Effect.fail(notFound(command, cwd)); + asked.push([command, ...args].join(" ")); + return Deferred.await(release).pipe(Effect.andThen(Effect.fail(notFound(command, cwd)))); }, }); @@ -505,10 +409,8 @@ it.effect("asks once for every checkout of a server no integration claims", () = ); const once = asked.slice(); assert.sameMembers(once, [ - "credential git.example.internal", - "glab auth status --hostname Git.Example.Internal", - "fj", - "tea", + "managed git.example.internal", + "gitlab-cli auth status --hostname git.example.internal", ]); yield* TestClock.adjust("5 minutes"); @@ -521,32 +423,22 @@ it.effect("asks again for a checkout whose remote is configured on another serve Effect.gen(function* () { const registry = yield* makeRegistry({ remotes: [], - process: { - run: ({ command, args, cwd }) => - command === "git" && args[0] === "config" && cwd === "/rewritten" - ? Effect.succeed(processOutput("https://forge.example/team/rewritten\n")) - : command === "git" - ? Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(1) })) - : Effect.fail(notFound(command, cwd)), - }, - githubApi: { - credential: (host) => Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - }, - forgejo: { - listLogins: ({ command = "tea" }) => + discovery: [ + // Forgejo reads the configured remote, so only the rewritten checkout is its own. + managedCli("forgejo", ({ cwd }) => Effect.succeed( - command === "fj" - ? [ - { - name: "forge.example", - url: "https://forge.example", - user: "", - default: "false", - }, - ] - : [], + cwd === "/rewritten" + ? { kind: kind("forgejo"), name: "Forgejo", baseUrl: "https://forge.example" } + : null, ), - }, + ), + ], + run: ({ args, cwd }) => + Effect.succeed( + args[0] === "config" && cwd === "/rewritten" + ? TestSourceControlHost.processOutput("https://forge.example/team/rewritten\n") + : TestSourceControlHost.processOutput(""), + ), }); const direct = yield* resolveUnknown(registry, "direct"); assert.strictEqual(direct.provider.kind, "unknown"); @@ -559,20 +451,27 @@ it.effect("asks again for a checkout whose remote is configured on another serve it.effect("asks again after a check that could not answer", () => Effect.gen(function* () { let asks = 0; + let managedAsks = 0; const registry = yield* makeRegistry({ remotes: [], - process: { - run: ({ command, cwd }) => { - if (command !== "git") asks += 1; - return Effect.fail( - new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }), - ); - }, + discovery: [ + claimingCli("gitlab"), + managedCli("github", () => { + managedAsks += 1; + return Effect.fail(new NotAnswered()); + }), + ], + run: ({ command, cwd }) => { + if (command !== "git") asks += 1; + return Effect.fail( + new VcsProcessTimeoutError({ operation: "test", command, cwd, timeoutMs: 1 }), + ); }, }); yield* resolveUnknown(registry, "a"); yield* resolveUnknown(registry, "b"); assert.strictEqual(asks, 2); + assert.strictEqual(managedAsks, 2); }), ); @@ -580,7 +479,7 @@ it.effect("asks again once Settings change", () => Effect.gen(function* () { const changes = yield* PubSub.unbounded(); const handled = yield* Deferred.make(); - let signedIn = false; + let claims = false; const registry = yield* makeRegistry({ remotes: [], settings: { @@ -597,16 +496,18 @@ it.effect("asks again once Settings change", () => ), ), }, - process: { run: ({ command, cwd }) => Effect.fail(notFound(command, cwd)) }, - githubApi: { - credential: (host) => - signedIn - ? Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:fingerprint` }) - : Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })), - }, + discovery: [ + managedCli("github", ({ context }) => + Effect.succeed( + claims + ? { kind: kind("github"), name: "GitHub", baseUrl: context.provider.baseUrl } + : null, + ), + ), + ], }); assert.strictEqual((yield* resolveUnknown(registry, "a")).provider.kind, "unknown"); - signedIn = true; + claims = true; assert.strictEqual((yield* resolveUnknown(registry, "b")).provider.kind, "unknown"); yield* PubSub.publish(changes, DEFAULT_SERVER_SETTINGS); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index d6a36ba093459..5b58c6550bfaf 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -12,10 +12,9 @@ import { SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; -import type {} from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as BuiltInDrivers from "./builtInDrivers.ts"; +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import { probeSourceControlProvider, @@ -72,11 +71,10 @@ export class SourceControlProviderRegistry extends Context.Service< function unsupportedProvider( kind: SourceControlProviderKind, ): SourceControlProvider.SourceControlProvider["Service"] { + // Binding context does not create a second provider-operation span. return SourceControlProvider.SourceControlProvider.of({ kind, - // A remote on a host no provider recognizes (or no remote at all) has no change - // requests T3 could know about. Answering with none keeps the status poller from - // failing, and warning, on every refresh of such a repository. + // Unknown hosts have no readable change requests; return none to avoid poll warnings on every refresh. listChangeRequests: (input) => kind === "unknown" ? Effect.succeed([]) @@ -144,7 +142,7 @@ function selectProviderContext( ): SourceControlProvider.SourceControlProviderContext | null { const candidates: Array = []; for (const remote of remotes) { - const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + const provider = sourceControlDefinitions.recognizeRemote(remote.url); if (provider) { candidates.push({ provider, @@ -166,11 +164,9 @@ const remoteServerOf = (remoteUrl: string) => remoteUrl.trim().replace(/(? - provider.listChangeRequests({ + listChangeRequests: Effect.fnUntraced(function* (input) { + return yield* provider.listChangeRequests({ ...input, context: input.context ?? context, - }), - getChangeRequest: (input) => - provider.getChangeRequest({ + }); + }), + getChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.getChangeRequest({ ...input, context: input.context ?? context, - }), - createChangeRequest: (input) => - provider.createChangeRequest({ + }); + }), + createChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.createChangeRequest({ ...input, context: input.context ?? context, - }), - getRepositoryCloneUrls: (input) => - provider.getRepositoryCloneUrls({ + }); + }), + getRepositoryCloneUrls: Effect.fnUntraced(function* (input) { + return yield* provider.getRepositoryCloneUrls({ ...input, context: input.context ?? context, - }), - createRepository: (input) => provider.createRepository(input), - getDefaultBranch: (input) => - provider.getDefaultBranch({ + }); + }), + createRepository: Effect.fnUntraced(function* (input) { + return yield* provider.createRepository(input); + }), + getDefaultBranch: Effect.fnUntraced(function* (input) { + return yield* provider.getDefaultBranch({ ...input, context: input.context ?? context, - }), - checkoutChangeRequest: (input) => - provider.checkoutChangeRequest({ + }); + }), + checkoutChangeRequest: Effect.fnUntraced(function* (input) { + return yield* provider.checkoutChangeRequest({ ...input, context: input.context ?? context, - }), + }); + }), }); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig.ServerConfig; @@ -368,12 +372,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit if (input.url.protocol !== "https:" || input.url.username || input.url.password) { return undefined; } - const kind = detectSourceControlProviderFromRemoteUrl(input.url.href)?.kind; + const kind = sourceControlDefinitions.recognizeRemote(input.url.href)?.kind; return kind ? providers.get(kind)?.resolveLink?.(input) : undefined; }, get, resolveHandle, - resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + resolve: Effect.fn("SourceControlProviderRegistry.resolve")(function (input) { + return resolveHandle(input).pipe(Effect.map((handle) => handle.provider)); + }), discover: Effect.forEach( discoverySpecs, (spec) => @@ -388,17 +394,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }, ); -export const make = Effect.gen(function* () { - const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) => - driver.make.pipe( - Effect.map((instance): SourceControlProviderRegistration => ({ - kind: driver.kind, - provider: instance.sourceControl, - discovery: instance.discovery, - })), - ), +const make = Effect.gen(function* () { + const drivers = yield* BuiltInDrivers.SourceControlDrivers; + return yield* makeWithProviders( + drivers.map(({ driver, instance }): SourceControlProviderRegistration => ({ + kind: driver.kind, + provider: instance.sourceControl, + discovery: instance.discovery, + })), ); - return yield* makeWithProviders(drivers); }); export const layer = Layer.effect(SourceControlProviderRegistry, make); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 14615a39856d6..4a9105e9f5d9e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -101,6 +101,51 @@ function layer(input: { : layerService.pipe(Layer.provideMerge(NodeServices.layer)); } +const lookupFailingWith = (cause: unknown, detail: string) => + Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service + .lookupRepository({ provider: SourceControlProviderKind.make("bitbucket"), repository: "x" }) + .pipe(Effect.flip); + }).pipe( + Effect.provide( + layer({ + provider: makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.fail( + new SourceControlProviderError({ + provider: SourceControlProviderKind.make("bitbucket"), + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + detail, + cause, + }), + ), + }), + }), + ), + ); + +it.effect("tells the user a host's fixed explanation of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + { userFacingDetail: "Repositories are named workspace/repository." }, + "Repositories are named workspace/repository.", + ); + assert.strictEqual(error.detail, "Repositories are named workspace/repository."); + }), +); + +it.effect("keeps a host's raw response text out of a failed repository lookup", () => + Effect.gen(function* () { + const error = yield* lookupFailingWith( + new Error("upstream"), + "internal token=abc", + ); + assert.strictEqual(error.detail, "The source control operation could not be completed."); + }), +); + it.effect("clones a looked-up repository after creating missing parent directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index cba0df9d90564..7c34e6486864d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -30,14 +30,13 @@ import { type GitCloneProgressLine, } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); const isSourceControlProviderError = Schema.is(SourceControlProviderError); const isGitCommandError = Schema.is(GitCommandError); -const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, @@ -74,14 +73,10 @@ export interface SourceControlCloneOptions { readonly timeoutMs?: number | null; } -// The synchronous RPC (older clients, mobile) keeps a deadline: nothing else -// tells the user a clone stalled. The tracked path passes null and relies on -// progress and Cancel instead. +// Older/mobile synchronous RPCs need deadlines; tracked clones use progress and Cancel instead. const CLONE_TIMEOUT_MS = 120_000; const CLONE_ENV = { - // `--progress` forces the transfer counters through the pipe; the delay env - // makes the checkout counter start immediately. No tty means a credential - // prompt would hang forever, so tell git to fail instead. + // Force piped progress immediately; refuse credential prompts that would hang without a tty. GIT_PROGRESS_DELAY: "0", GIT_TERMINAL_PROMPT: "0", LC_ALL: "C", @@ -95,11 +90,9 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi operation, provider, detail: - isSourceControlProviderError(cause) && - cause.provider === "bitbucket" && - isBitbucketRepositoryLocatorError(cause.cause) - ? BitbucketApi.BitbucketRepositoryLocatorError.detail - : "The source control operation could not be completed.", + (isSourceControlProviderError(cause) + ? SourceControlProvider.userFacingSourceControlDetail(cause.cause) + : undefined) ?? "The source control operation could not be completed.", cause, }), ); @@ -117,11 +110,7 @@ function toRepositoryInfo( }; } -/** - * The URL clients see. A pasted `https://user:token@host/…` must not travel - * back over `subscribeProjectClones` to every reader; git still gets the - * original. - */ +// Redact credentials from client clone snapshots; git still receives the original URL. function redactRemoteUrl(remoteUrl: string): string { try { const url = new URL(remoteUrl); @@ -138,20 +127,15 @@ function redactRemoteUrl(remoteUrl: string): string { } } -// Userinfo may itself contain `@`; everything up to the last one before the -// host boundary goes. +// Userinfo can contain @; remove through the last one before the host boundary. const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; function redactUrlCredentials(text: string): string { return text.replace(URL_WITH_USERINFO, "$1"); } -/** - * Explains a failed first push of a published repository. The repository - * already exists by then, so publishing again cannot help; the user has to - * fix access and push. Built from fixed text and the provider's URLs only, - * since git's stderr can carry credentials. - */ +// The remote already exists after push failure; advise fixing access and pushing. +// Use fixed text and provider URLs because git stderr can expose credentials. function publishPushFailureDetail(input: { readonly urls: SourceControlRepositoryCloneUrls; readonly remoteName: string; @@ -174,12 +158,8 @@ function publishPushFailureDetail(input: { } /** - * Adding a project clones over SSH for most hosts, and a machine with no SSH - * key there would fail every time, while the host's HTTPS credential helper - * may be set up. A clone whose SSH login was refused is retried once over - * HTTPS, unless the caller asked for SSH. A looked-up repository has the - * provider's own HTTPS URL; any other URL is rewritten, which is refused - * where it would have to guess. + * SSH rejection can be retried over HTTPS when the host's credential helper is available. + * Explicit SSH requests must not fall back; unknown URL shapes must not be guessed. */ function httpsFallbackUrl( input: SourceControlCloneRepositoryInput, @@ -207,7 +187,7 @@ function selectRemoteUrl( } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -347,9 +327,7 @@ export const make = Effect.gen(function* () { options?: SourceControlCloneOptions, ) { const onProgress = options?.onProgress; - // Git interleaves progress redraws with its real messages on stderr. The - // last non-progress lines are what explain a failure ("Repository not - // found", "Permission denied"), so keep them for the error detail. + // Git mixes progress and diagnostics on stderr; retain non-progress tail lines for failure details. const stderrTail: Array = []; const onStderrLine = (line: string) => { const parsed = parseGitCloneProgressLine(line); @@ -368,9 +346,7 @@ export const make = Effect.gen(function* () { cwd: path.dirname(destinationPath), args: ["clone", "--progress", "--", cloneUrl, path.basename(destinationPath)], timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, - // Progress redraws add up on a slow multi-GB clone. The buffered copy - // is never read (the tail is kept by hand above), so keep it small - // and let the line callbacks keep flowing past the cap. + // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, appendTruncationMarker: true, keepLineCallbacksAfterTruncation: true, @@ -406,6 +382,7 @@ export const make = Effect.gen(function* () { options, ).pipe( Effect.as(prepared.remoteUrl), + // @effect-diagnostics-next-line catchConditionalRefailToCatchIf:off - The condition also narrows httpsUrl for the fallback closure. Effect.catch((sshError) => httpsUrl === null || !isSshRejection(sshError) ? Effect.fail(sshError) @@ -435,11 +412,8 @@ export const make = Effect.gen(function* () { destinationPath: string, ) { const normalized = yield* normalizeDestinationPath(destinationPath); - // Only what git left behind may go. The destination was empty when the - // clone started, so anything without a `.git` inside was put there by - // someone else since; refuse rather than delete their files. - // A missing destination is already discarded; any other read failure - // (a file in its place, permissions) is not something to remove through. + // Only .git-bearing partial clones may be deleted; other files arrived after the empty-directory check. + // Missing destinations are already discarded; other read failures must refuse removal. const entries = yield* fileSystem.readDirectory(normalized).pipe( Effect.catchIf( (cause) => cause.reason._tag === "NotFound", @@ -462,9 +436,7 @@ export const make = Effect.gen(function* () { detail: "Destination path contains files that are not from the clone.", }); } - // The directory itself is the project's workspace root and must stay; - // only git's partial contents go. An interrupted git may still be closing - // files, so removal retries briefly. + // Preserve the workspace directory after removing partial contents; retry while interrupted git closes files. yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), @@ -499,10 +471,7 @@ export const make = Effect.gen(function* () { url: remoteUrl, }); - // An empty local repo (no commits) would make `git push HEAD:...` fail - // with an opaque "src refspec HEAD does not match any". Treat this as a - // partial success: the remote was created and wired up, but there is - // nothing to push yet. + // Empty repos cannot push HEAD; report partial success because the remote already exists. const hasCommits = yield* git .execute({ operation: "SourceControlRepositoryService.publishRepository.headCheck", @@ -552,29 +521,39 @@ export const make = Effect.gen(function* () { }, ); + // Forwarders retain the underlying operation spans. return SourceControlRepositoryService.of({ - lookupRepository: (input) => - lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), - prepareClone: (input) => - prepareClone(input).pipe( + lookupRepository: Effect.fnUntraced(function* (input) { + return yield* lookupRepository(input).pipe( + mapRepositoryError("lookupRepository", input.provider), + ); + }), + prepareClone: Effect.fnUntraced(function* (input) { + return yield* prepareClone(input).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - cloneRepository: (input, options) => - cloneRepository(input, options).pipe( + ); + }), + cloneRepository: Effect.fnUntraced(function* (input, options) { + return yield* cloneRepository(input, options).pipe( mapRepositoryError( "cloneRepository", input.provider ?? SourceControlProviderKind.make("unknown"), ), - ), - discardClone: (destinationPath) => - discardClone(destinationPath).pipe( + ); + }), + discardClone: Effect.fnUntraced(function* (destinationPath) { + return yield* discardClone(destinationPath).pipe( mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), - ), - publishRepository: (input) => - publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + ); + }), + publishRepository: Effect.fnUntraced(function* (input) { + return yield* publishRepository(input).pipe( + mapRepositoryError("publishRepository", input.provider), + ); + }), }); }); diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts index 9af54853a049b..0e994dcac4a7f 100644 --- a/apps/server/src/sourceControl/builtInDrivers.ts +++ b/apps/server/src/sourceControl/builtInDrivers.ts @@ -1,51 +1,31 @@ -import * as AzureDevOpsCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsCli"; -import * as AzureDevOpsPullRequestCli from "@t3tools/source-control-azure-devops/server/AzureDevOpsPullRequestCli"; -import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; -import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; -import * as BitbucketPullRequestApi from "@t3tools/source-control-bitbucket/server/BitbucketPullRequestApi"; -import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; -import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli"; -import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; -import * as GitCafeApi from "@t3tools/source-control-gitcafe/server/GitCafeApi"; -import * as GitCafeCredentials from "@t3tools/source-control-gitcafe/server/GitCafeCredentials"; -import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; -import * as GitHubRest from "@t3tools/source-control-github/server/GitHubRest"; -import * as GitHubPullRequestApi from "@t3tools/source-control-github/server/GitHubPullRequestApi"; -import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; -import * as GitLabCli from "@t3tools/source-control-gitlab/server/GitLabCli"; -import * as GitLabPullRequestCli from "@t3tools/source-control-gitlab/server/GitLabPullRequestCli"; -import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; -import type { SourceControlDriver } from "@t3tools/source-control-core/server/driver"; +import type { + SourceControlDriver, + SourceControlDriverInstance, +} from "@t3tools/source-control-core/server/driver"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ServerSourceControlHost from "./ServerSourceControlHost.ts"; +import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControlDefinitions.ts"; -const drivers = [ - GitHubDriver.driver, - GitLabDriver.driver, - AzureDevOpsDriver.driver, - BitbucketDriver.driver, - ForgejoDriver.driver, - GitCafeDriver.driver, -]; +export interface SourceControlDriverEntry { + readonly driver: Pick; + readonly instance: SourceControlDriverInstance; +} -export type BuiltInSourceControlDriversEnv = - (typeof drivers)[number] extends SourceControlDriver ? R : never; +/** Every driver's instance, in registry order. Everything that reads a host reads it here. */ +export class SourceControlDrivers extends Context.Service< + SourceControlDrivers, + ReadonlyArray +>()("t3/sourceControl/builtInDrivers/SourceControlDrivers") {} -export const BUILT_IN_SOURCE_CONTROL_DRIVERS: ReadonlyArray< - SourceControlDriver -> = drivers; +const make = Effect.forEach(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, (driver) => + driver.make.pipe(Effect.map((instance) => ({ driver, instance }))), +); -export const layer = Layer.mergeAll( - // `GitHubRest.layerWithDependencies` carries the quota reserve and rate-limit pause every GitHub - // reader shares, so the server builds it once, here. - GitHubPullRequestApi.layer.pipe(Layer.provideMerge(GitHubRest.layerWithDependencies)), - AzureDevOpsPullRequestCli.layer.pipe(Layer.provideMerge(AzureDevOpsCli.layer)), - BitbucketPullRequestApi.layer.pipe( - Layer.provideMerge(BitbucketApi.layer.pipe(Layer.provide(SourceControlRateLimit.layer))), - ), - ForgejoCli.layer, - GitCafeApi.layer.pipe(Layer.provideMerge(GitCafeCredentials.layer)), - GitLabPullRequestCli.layer.pipe(Layer.provideMerge(GitLabCli.layer)), -).pipe(Layer.provideMerge(ServerSourceControlHost.layer)); +export const layer = Layer.effect(SourceControlDrivers, make).pipe( + Layer.provideMerge(ServerSourceControlHost.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts new file mode 100644 index 0000000000000..e21efd8173a73 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "@effect/vitest"; + +import { + BUILT_IN_SOURCE_CONTROL_DRIVER_LIST, + sourceControlDefinitions, +} from "./sourceControlDefinitions.ts"; + +it("runs a driver for every host the server and clients read, in their order", () => { + expect(BUILT_IN_SOURCE_CONTROL_DRIVER_LIST.map((driver) => driver.definition)).toEqual( + sourceControlDefinitions.definitions, + ); +}); diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.ts b/apps/server/src/sourceControl/sourceControlDefinitions.ts new file mode 100644 index 0000000000000..eaeb8dd55f110 --- /dev/null +++ b/apps/server/src/sourceControl/sourceControlDefinitions.ts @@ -0,0 +1,19 @@ +import * as AzureDevOpsDriver from "@t3tools/source-control-azure-devops/server/driver"; +import * as BitbucketDriver from "@t3tools/source-control-bitbucket/server/driver"; +import { builtInSourceControlDefinitions } from "@t3tools/source-control-definitions/registry"; +import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver"; +import * as GitCafeDriver from "@t3tools/source-control-gitcafe/server/driver"; +import * as GitHubDriver from "@t3tools/source-control-github/server/driver"; +import * as GitLabDriver from "@t3tools/source-control-gitlab/server/driver"; + +/** In the shared registry's order, which is the order hosts are listed and probed in. */ +export const BUILT_IN_SOURCE_CONTROL_DRIVER_LIST = [ + GitHubDriver.driver, + GitLabDriver.driver, + GitCafeDriver.driver, + ForgejoDriver.driver, + BitbucketDriver.driver, + AzureDevOpsDriver.driver, +] as const; + +export const sourceControlDefinitions = builtInSourceControlDefinitions; diff --git a/apps/server/src/sourceControl/sourceControlHostSecrets.ts b/apps/server/src/sourceControl/sourceControlHostSecrets.ts index 8ac8046985ce6..46c96ca5b7f05 100644 --- a/apps/server/src/sourceControl/sourceControlHostSecrets.ts +++ b/apps/server/src/sourceControl/sourceControlHostSecrets.ts @@ -1,26 +1,12 @@ import { secretSettingsFields } from "@t3tools/contracts"; -import * as AzureDevOps from "@t3tools/source-control-azure-devops/client/definition"; -import * as Bitbucket from "@t3tools/source-control-bitbucket/client/definition"; -import type { SourceControlClientDefinition } from "@t3tools/source-control-core/client/definition"; -import * as Forgejo from "@t3tools/source-control-forgejo/client/definition"; -import * as GitCafe from "@t3tools/source-control-gitcafe/client/definition"; -import * as GitHub from "@t3tools/source-control-github/client/definition"; -import * as GitLab from "@t3tools/source-control-gitlab/client/definition"; -const definitions: ReadonlyArray = [ - GitHub.definition, - GitLab.definition, - AzureDevOps.definition, - Bitbucket.definition, - Forgejo.definition, - GitCafe.definition, -]; +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; export const SOURCE_CONTROL_HOST_SECRET_FIELDS: ReadonlyMap< string, ReadonlyArray > = new Map( - definitions.flatMap((definition) => { + sourceControlDefinitions.definitions.flatMap((definition) => { const fields = definition.settings ? secretSettingsFields(definition.settings) : []; return fields.length > 0 ? [[definition.kind, fields] as const] : []; }), diff --git a/apps/server/src/sourceControl/threadPullRequests.ts b/apps/server/src/sourceControl/threadPullRequests.ts new file mode 100644 index 0000000000000..d84d2cd1b5ab9 --- /dev/null +++ b/apps/server/src/sourceControl/threadPullRequests.ts @@ -0,0 +1,14 @@ +import { makeThreadPullRequests } from "@t3tools/source-control-core/shared/threadPullRequests"; + +import { sourceControlDefinitions } from "./sourceControlDefinitions.ts"; + +export const { + normalizeThreadPullRequestKey, + legacyThreadPullRequestKey, + visibleThreadPullRequests, + threadPullRequestKeysEqual, + threadPullRequestKeyOf, + resolveThreadPullRequestChains, + threadPullRequestsOf, + applyThreadPullRequestLinkUpdate, +} = makeThreadPullRequests(sourceControlDefinitions); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index f9fc52750d23f..d9d3bbfc52107 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,4 +1,6 @@ -import { expect, it } from "@effect/vitest"; +import type * as NodeOS from "node:os"; + +import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -10,12 +12,22 @@ import { ServerConfig, layerTest } from "./config.ts"; import { EnvironmentAuth } from "./auth/EnvironmentAuth.ts"; import { + acceptsPrivateNetworkConnection, formatHeadlessServeOutput, issueHeadlessServeAccessInfo, renderTerminalQrCode, resolveHeadlessConnectionString, } from "./startupAccess.ts"; +const ipv4 = (address: string, internal = false): NodeOS.NetworkInterfaceInfo => ({ + address, + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal, + cidr: `${address}/24`, +}); + it.effect("prints the public URL and QR at startup with a loopback listener", () => Effect.gen(function* () { const config = yield* ServerConfig; @@ -52,27 +64,58 @@ it.effect("prints the public URL and QR at startup with a loopback listener", () it("resolves wildcard hosts to a concrete external interface when one is available", () => { const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { - en0: [ - { - address: "192.168.1.42", - netmask: "255.255.255.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: false, - cidr: "192.168.1.42/24", - }, - ], - lo0: [ - { - address: "127.0.0.1", - netmask: "255.0.0.0", - family: "IPv4", - mac: "00:00:00:00:00:00", - internal: true, - cidr: "127.0.0.1/8", - }, - ], + interfaces: { en0: [ipv4("192.168.1.42")], lo0: [ipv4("127.0.0.1", true)] }, }); expect(connectionString).toBe("http://192.168.1.42:3773"); }); + +it("skips public and container addresses for a private-network listener", () => { + const interfaces = { + eth0: [ipv4("203.0.113.7")], + docker0: [ipv4("172.17.0.1")], + bridge200: [ipv4("192.168.64.1")], + eth1: [ipv4("10.0.0.5")], + }; + + expect(resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces })).toBe( + "http://203.0.113.7:3773", + ); + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces, privateNetworkOnly: true }), + ).toBe("http://10.0.0.5:3773"); + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { eth0: [ipv4("203.0.113.7")] }, + privateNetworkOnly: true, + }), + ).toBe("http://localhost:3773"); +}); + +it("prefers a LAN address over a tailnet one for a private-network pairing URL", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { tailscale0: [ipv4("100.101.102.103")], en0: [ipv4("192.168.1.42")] }, + privateNetworkOnly: true, + }), + ).toBe("http://192.168.1.42:3773"); +}); + +describe("acceptsPrivateNetworkConnection", () => { + const accepts = (localAddress: string | undefined, remoteAddress: string | undefined) => + acceptsPrivateNetworkConnection({ localAddress, remoteAddress }); + + it("serves loopback, LAN, and tailnet clients", () => { + expect(accepts("127.0.0.1", "127.0.0.1")).toBe(true); + expect(accepts("::ffff:192.168.1.20", "::ffff:192.168.1.31")).toBe(true); + expect(accepts("100.101.102.103", "100.90.1.2")).toBe(true); + }); + + it("closes connections from or to a public address", () => { + // A cloud VM's public address is NATed to a private interface address. + expect(accepts("172.31.4.10", "203.0.113.7")).toBe(false); + expect(accepts("203.0.113.7", "192.168.1.31")).toBe(false); + expect(accepts("169.254.10.1", "169.254.10.2")).toBe(false); + expect(accepts(undefined, "192.168.1.31")).toBe(false); + }); +}); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 34197a75c6ade..4e53bdbff9d07 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,5 +1,7 @@ +import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; +import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; @@ -36,6 +38,38 @@ export const isWildcardHost = (host: string | undefined): boolean => export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +/** + * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and + * macOS's `bridge100`+) have private addresses only this machine reaches. A + * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so + * it stays listed. + */ +export const VIRTUAL_INTERFACE = + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; + +/** + * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in + * plain or IPv4-mapped IPv6 form. + */ +export const isPrivateNetworkAddress = (address: string): boolean => { + const ipv4 = address.replace(/^::ffff:/i, ""); + return NodeNet.isIPv4(ipv4) && !ipv4.startsWith("169.254.") && isPrivateNetworkHost(ipv4); +}; + +/** + * A private-network listener binds every interface but serves only + * connections that stay on loopback, a LAN, or the tailnet. Both ends must be + * private: a cloud VM or a router port forward hands public clients to a + * private local address. + */ +export const acceptsPrivateNetworkConnection = (socket: { + readonly localAddress?: string | undefined; + readonly remoteAddress?: string | undefined; +}): boolean => + [socket.localAddress, socket.remoteAddress].every( + (address) => address !== undefined && isPrivateNetworkAddress(address), + ); + export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): string => address._tag === "InetAddressV6" && NetAddress.isLoopback(address.address) ? "[::1]" @@ -50,7 +84,8 @@ const isIpv6Family = (family: string | number): boolean => family === "IPv6" || const resolveHeadlessConnectionHost = ( host: string | undefined, - interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), + interfaces: NetworkInterfacesMap, + privateNetworkOnly: boolean, ): string => { if (!host) { return "localhost"; @@ -60,13 +95,26 @@ const resolveHeadlessConnectionHost = ( return normalizeHost(host); } - const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); - const externalIpv4 = interfaceEntries.find( - (entry) => !entry.internal && isIpv4Family(entry.family), + const interfaceEntries = Object.entries(interfaces).flatMap(([name, entries]) => + privateNetworkOnly && VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []), ); + const externalIpv4Entries = interfaceEntries.filter( + (entry) => + !entry.internal && + isIpv4Family(entry.family) && + (!privateNetworkOnly || isPrivateNetworkAddress(entry.address)), + ); + // A tailnet or other CGNAT address reaches fewer devices than the LAN one. + const externalIpv4 = privateNetworkOnly + ? (externalIpv4Entries.find((entry) => !isSharedAddressSpaceHost(entry.address)) ?? + externalIpv4Entries[0]) + : externalIpv4Entries[0]; if (externalIpv4) { return externalIpv4.address; } + if (privateNetworkOnly) { + return "localhost"; + } const externalIpv6 = interfaceEntries.find( (entry) => !entry.internal && isIpv6Family(entry.family), @@ -77,9 +125,16 @@ const resolveHeadlessConnectionHost = ( export const resolveHeadlessConnectionString = ( host: string | undefined, port: number, - interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), + options: { + readonly interfaces?: NetworkInterfacesMap; + readonly privateNetworkOnly?: boolean | undefined; + } = {}, ): string => { - const connectionHost = resolveHeadlessConnectionHost(host, interfaces); + const connectionHost = resolveHeadlessConnectionHost( + host, + options.interfaces ?? NodeOS.networkInterfaces(), + options.privateNetworkOnly === true, + ); return `http://${formatHostForUrl(connectionHost)}:${port}`; }; @@ -145,6 +200,7 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), + { privateNetworkOnly: serverConfig.privateNetworkOnly }, ); const issued = yield* serverAuth.issueStartupPairingCredential(); diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b8332de492884..c05d4aed4cfbf 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -750,7 +750,6 @@ describe("subagent worktree ownership", () => { id: ThreadId.make("grandchild"), lineage: { ...child.lineage, parentThreadId: child.id }, }; - // The worktree owner can follow its children in the snapshot. fixture.setThreads( scenario === "grandchild" ? [grandchild, child, root] diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 471e78df2a3b1..526aaffa2abb3 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -67,8 +67,7 @@ const decodePnpmModulesManifest = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; -// After a settle, the project's settle script needs time to open its -// terminal, which then protects the worktree. +// Give settle scripts time to open terminals that protect the worktree. const SETTLE_SCRIPT_GRACE_MS = 60_000; // Worktree creation can precede its thread row, including review-guide checkouts. const ORPHAN_WORKTREE_GRACE_MS = 60 * 60 * 1_000; @@ -173,11 +172,7 @@ function worktreeOwner null), ); - // The snapshot takes seconds on large databases, so it runs on the reader - // worker instead of the event loop. + // Large snapshots take seconds; use the reader worker instead of blocking the event loop. const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const snapshot = yield* projections.getShellSnapshot().pipe(NodeSqliteClient.readOnly); const projects = yield* projectStore.listShells(); @@ -362,7 +356,6 @@ const make = Effect.gen(function* () { projects: ReadonlyArray<{ readonly workspaceRoot: string }>, entries: StorageCleanupReportEntry[], ) { - // Include deleted and archived threads, even those without a branch or project. const rows = yield* sql<{ worktreePath: string }>` SELECT DISTINCT json_extract(payload_json, '$.worktreePath') AS "worktreePath" FROM orchestration_v2_projection_threads @@ -414,9 +407,7 @@ const make = Effect.gen(function* () { } return result; }); - // Let the first walk reach its own limit so unmeasurable folders get cached. - // Later walks share the remaining sweep budget. - // Unmeasured folders still appear with an unknown size. + // Let the first walk time out and cache unmeasurable folders; later walks share the remaining budget. const now = yield* Clock.currentTimeMillis; const measurementDeadline = now + 30_000; let firstMeasurement = true; @@ -439,7 +430,7 @@ const make = Effect.gen(function* () { const remaining = measurementDeadline - (yield* Clock.currentTimeMillis); if (!orphanSizes.has(key) && (firstMeasurement || remaining > 0)) { const measured = yield* measureWorktree(worktreePath).pipe( - firstMeasurement ? Effect.map(Option.some) : Effect.timeoutOption(remaining), + firstMeasurement ? Effect.asSome : Effect.timeoutOption(remaining), ); firstMeasurement = false; if (Option.isSome(measured)) orphanSizes.set(key, measured.value); @@ -482,9 +473,7 @@ const make = Effect.gen(function* () { } }); - // Whether cwd stopped being a checkout of this branch. Two small Git reads, - // since every worktree in a sweep is asked; statusDetailsLocal also diffs and - // counts. + // Use two small Git reads: statusDetailsLocal also diffs and counts for every worktree. const branchChanged = Effect.fn("StorageCleanup.branchChanged")(function* ( cwd: string, branch: string | null, @@ -496,8 +485,7 @@ const make = Effect.gen(function* () { cwd, detail, }); - // Null when cwd is not a repository, or is gone. Git's own messages are - // matched, so they must not be translated. + // Return null for missing/non-repository cwd; force untranslated git diagnostics for matching. const read = Effect.fnUntraced(function* (args: string[], allowedExitCode?: number) { const result = yield* git .execute({ @@ -527,8 +515,7 @@ const make = Effect.gen(function* () { return yield* failure( "Git index is locked. Status will resume when the index lock is removed.", ); - // Exits 1 for a detached HEAD. Reads the full ref name, which is never - // shortened to "heads/" when a tag shares the branch name. + // Detached HEAD exits 1; full refs avoid shortening when a tag shares the branch name. const head = yield* read(["symbolic-ref", "--quiet", "HEAD"], 1); if (head === null) return true; const ref = head.exitCode === 0 ? head.stdout.trim() : ""; @@ -705,14 +692,12 @@ const make = Effect.gen(function* () { timeoutMs: 300_000, }); if (result.exitCode !== 0) - return yield* Effect.fail( - new GitCommandError({ - operation: "StorageCleanup.cleanupGit", - command, - cwd, - detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", - }), - ); + return yield* new GitCommandError({ + operation: "StorageCleanup.cleanupGit", + command, + cwd, + detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + }); }); const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( @@ -809,8 +794,7 @@ const make = Effect.gen(function* () { if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); if (hasTerminal(worktreePath)) return keep("open terminal"); - // Roots are canonical, so compare canonical paths. A symlinked parent - // (a linked drive) is fine; a symlinked worktree directory is not. + // Compare canonical roots while allowing symlinked parents, never symlinked worktree directories. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); if (realPath !== path.join(realParent, path.basename(worktreePath))) @@ -932,10 +916,7 @@ const make = Effect.gen(function* () { ? "not merged" : "has commits beyond the default branch", ); - // Threads, queued deletion work and provider sessions as they are now, so a - // queued turn, resumed session or new thread sharing this path cancels - // the removal. Read only this path's threads: a full snapshot per - // worktree takes seconds on large databases. + // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. const blockedSinceCheck = Effect.fnUntraced(function* () { const latestProjects = yield* projectStore.listShells(); if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) @@ -950,8 +931,7 @@ const make = Effect.gen(function* () { .worktreeOnDelete ) return "thread or cleanup settings changed since check"; - // V2 deletion queues durable cleanup. Do not remove its checkout until - // every effect has finished successfully or was explicitly cancelled. + // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout. const pendingCleanup = yield* sql` SELECT 1 FROM orchestration_v2_effect_outbox WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 @@ -989,8 +969,7 @@ const make = Effect.gen(function* () { const sessions = yield* Effect.forEach(sessionRows, (row) => decodeCleanupSession(row.payload_json), ); - // A stored session can stay ready after its runtime was released, - // for example once every thread detached; only a live one holds it. + // Ready persisted sessions may have released their runtime; only live sessions protect the path. for (const session of sessions) { const cwd = path.resolve(session.cwd); if ( @@ -1046,8 +1025,7 @@ const make = Effect.gen(function* () { }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); - // Preserve branch and path: ProviderTurnStartService recreates the checkout - // from that branch when the thread is resumed. + // Preserve branch/path so ProviderTurnStartService can recreate the checkout on resume. yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id }); }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 8f402b6682f2a..64c401c7bb44d 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -1,8 +1,4 @@ -/** - * Events go to the T3 Code ingest endpoint, which keeps a copy and forwards - * them to PostHog. Each event carries a uuid, so PostHog can tell a retried - * copy from a new event. - */ +// T3 ingest retains events and forwards to PostHog; UUIDs deduplicate retries there. import * as HostProcess from "@t3tools/shared/HostProcess"; import type { ClientOs } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; @@ -40,8 +36,7 @@ interface DeliveryState { readonly retryAt: number; } -// The background flush checks for work at this interval, so a retry delay -// shorter than it waits for the next check. +// Retry delays shorter than this still wait for the next background check. const FLUSH_INTERVAL_MS = 10_000; // A hung send would hold the flush lock, and with it the shutdown flush. const SEND_TIMEOUT = "10 seconds"; @@ -52,19 +47,9 @@ const RETRY_BASE_DELAY_MS = 2_000; // server's own. const MAX_RECORDED_OWNERS = 1_000; const RETRY_MAX_DELAY_MS = 300_000; -// The endpoint answers these when the batch itself is wrong (bad body, wrong -// api_key, too large), so sending it again cannot help. 408 and 429 are not -// here because a later send can succeed. +// These statuses reject the batch itself; unlike 408/429, retrying cannot help. const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); -/** - * Release channel of this build. Release workflows set it for - * pingdotgg/t3code builds (see apps/server/vite.config.ts); every other build - * reports "dev". It is separate from `__T3CODE_BUILD_CHANNEL__`, which names - * stable "latest" and preview "nightly" for the sign-in page. - */ -declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; - /** `random` is in [0, 1). */ function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); @@ -110,9 +95,13 @@ export class AnalyticsService extends Context.Service< static readonly layerTest = Layer.succeed( AnalyticsService, AnalyticsService.of({ - record: () => Effect.void, + record: Effect.fn("AnalyticsService.record")(function () { + return Effect.void; + }), flush: Effect.void, - recordOwners: () => Effect.void, + recordOwners: Effect.fn("AnalyticsService.recordOwners")(function () { + return Effect.void; + }), }), ); } @@ -133,7 +122,7 @@ function serverOsFromNodePlatform(platform: string): ClientOs { } /** @public */ -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; @@ -153,8 +142,6 @@ export const make = Effect.gen(function* () { // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const telemetryChannel = - typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; @@ -209,7 +196,7 @@ export const make = Effect.gen(function* () { wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), arch: hostArchitecture, t3CodeVersion: packageJson.version, - telemetryChannel, + telemetryChannel: ServerConfig.telemetryChannel, clientType, serverOs: serverOsFromNodePlatform(hostPlatform), serverArch: hostArchitecture, @@ -234,8 +221,7 @@ export const make = Effect.gen(function* () { return [nextBatch, current.slice(nextBatch.length)] as const; }); - // A rejected batch is dropped at once, but the next batch still waits for the - // backoff, so a wrong api_key does not send every batch as fast as it can. + // Back off even after dropping rejected batches so bad API keys cannot drain the queue in a tight loop. const flush: AnalyticsService["Service"]["flush"] = Effect.gen(function* () { while (true) { const delivery = yield* Ref.get(deliveryRef); @@ -288,8 +274,7 @@ export const make = Effect.gen(function* () { function* (event, properties) { if (!telemetryConfig.enabled || !distinctId) return; - // Telemetry is best effort: an event without a uuid is not sent. The - // Node implementation throws (a defect) rather than failing, so catch both. + // Catch Node UUID defects too; best-effort telemetry drops events without UUIDs. const uuid = yield* Effect.exit(crypto.randomUUIDv7()); if (Exit.isFailure(uuid)) return; @@ -326,7 +311,7 @@ export const make = Effect.gen(function* () { ), ); - yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.flatMap(() => flushWhenDue)), { + yield* Effect.forever(Effect.sleep(FLUSH_INTERVAL_MS).pipe(Effect.andThen(flushWhenDue)), { disableYield: true, }).pipe(Effect.forkScoped); @@ -336,5 +321,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(AnalyticsService, make); - -const layerTest = AnalyticsService.layerTest; diff --git a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts index 724a74b5429b3..92a54ac8fcedb 100644 --- a/apps/server/src/telemetry/DelegatedTaskAnalytics.ts +++ b/apps/server/src/telemetry/DelegatedTaskAnalytics.ts @@ -1,8 +1,5 @@ /** - * Records one anonymous `mcp.delegated_task.finished` event when an app-owned - * delegated task reaches a final status, so we can see whether delegation - * between providers actually completes. The event carries the parent and child - * provider and model, the final status, and the child's runtime in seconds. + * Record one anonymous `mcp.delegated_task.finished` event per app-owned task reaching final status. * * @module DelegatedTaskAnalytics */ diff --git a/apps/server/src/telemetry/FeatureUsage.test.ts b/apps/server/src/telemetry/FeatureUsage.test.ts index e19e6a1c89685..9eac0b7ab7b5b 100644 --- a/apps/server/src/telemetry/FeatureUsage.test.ts +++ b/apps/server/src/telemetry/FeatureUsage.test.ts @@ -1,30 +1,99 @@ -import { ORCHESTRATION_V2_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; +import { + ORCHESTRATION_V2_WS_METHODS, + OrchestrationV2Command, + WS_METHODS, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; -import { featureUsage } from "./FeatureUsage.ts"; +import { commandFeatureUsage, featureUsage } from "./FeatureUsage.ts"; -const dispatch = ORCHESTRATION_V2_WS_METHODS.dispatchCommand; -const threadId = ThreadId.make("thread-private"); +const command = Schema.decodeUnknownSync(OrchestrationV2Command); +const ids = { commandId: "command-private", threadId: "thread-private" }; it("ignores messages, housekeeping commands, and untracked RPCs", () => { - expect(featureUsage(dispatch, { type: "message.dispatch", text: "secret prompt" })).toBe( - undefined, - ); - expect(featureUsage(dispatch, { type: "thread.visit", threadId })).toBe(undefined); + expect( + commandFeatureUsage( + command({ + ...ids, + type: "message.dispatch", + messageId: "message-private", + text: "secret prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }), + ), + ).toBe(undefined); + expect( + commandFeatureUsage( + command({ ...ids, type: "thread.visit", visitedAt: "2026-10-24T00:00:00.000Z" }), + ), + ).toBe(undefined); expect(featureUsage(WS_METHODS.terminalWrite, { data: "rm -rf" })).toBe(undefined); }); -it("reads RPC variants from enums, never from paths or text", () => { - expect( - featureUsage(WS_METHODS.shellOpenInEditor, { cwd: "/Users/someone/secret", editor: "cursor" }), - ).toEqual({ feature: "editor.open", variant: "cursor" }); +it("leaves thread launches to client.thread.started", () => { expect( featureUsage(ORCHESTRATION_V2_WS_METHODS.launchThread, { title: "Private title", workspaceStrategy: { type: "worktree", baseRef: "main", branch: "secret-branch" }, }), - ).toEqual({ feature: "thread.launch", variant: "worktree" }); + ).toBe(undefined); +}); + +it("tells watching a pull request apart from unwatching it", () => { + const watch = (watching: boolean) => + commandFeatureUsage( + command({ + ...ids, + type: "thread.pull-request.watch", + host: "github.com", + repository: "owner/private", + number: 7, + watching, + }), + ); + expect(watch(true)).toEqual({ feature: "thread.pull_request.watch", variant: "on" }); + expect(watch(false)).toEqual({ feature: "thread.pull_request.watch", variant: "off" }); +}); + +it("reads RPC variants from enums, never from paths or text", () => { + expect( + featureUsage(WS_METHODS.shellOpenInEditor, { cwd: "/Users/someone/secret", editor: "cursor" }), + ).toEqual({ feature: "editor.open", variant: "cursor" }); expect(featureUsage(WS_METHODS.terminalOpen, { cwd: "/Users/someone/secret" })).toEqual({ feature: "terminal.open", }); }); + +it("counts the owner's guest share actions, and only guest message sends", () => { + const send = (guestMessageId?: string) => + commandFeatureUsage( + command({ + ...ids, + type: "message.dispatch", + messageId: "message-private", + text: "secret prompt", + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + ...(guestMessageId === undefined ? {} : { guestMessageId }), + }), + ); + expect(send("guest-message-private")).toEqual({ feature: "guest_message.send" }); + expect(send()).toBe(undefined); + expect( + commandFeatureUsage( + command({ ...ids, type: "thread.guest-message.dismiss", guestMessageId: "guest-private" }), + ), + ).toEqual({ feature: "guest_message.dismiss" }); + expect(commandFeatureUsage(command({ ...ids, type: "thread.guest-share.close" }))).toEqual({ + feature: "guest_share.close", + }); + expect(featureUsage(WS_METHODS.guestShareCreate, { threadId: "thread-private" })).toEqual({ + feature: "guest_share.create", + }); +}); diff --git a/apps/server/src/telemetry/FeatureUsage.ts b/apps/server/src/telemetry/FeatureUsage.ts index c5f4903c35a13..30d32d67d4014 100644 --- a/apps/server/src/telemetry/FeatureUsage.ts +++ b/apps/server/src/telemetry/FeatureUsage.ts @@ -1,8 +1,4 @@ -import { - ORCHESTRATION_V2_WS_METHODS, - type OrchestrationV2Command, - WS_METHODS, -} from "@t3tools/contracts"; +import { type OrchestrationV2Command, WS_METHODS } from "@t3tools/contracts"; export interface FeatureUsage { readonly feature: string; @@ -18,14 +14,16 @@ const use = (feature: string, variant?: string): FeatureUsage => variant === undefined ? { feature } : { feature, variant }; /** - * User commands sent through `orchestration.dispatchCommand` that represent a - * feature choice. Housekeeping commands (visits, reorders, acknowledgements) - * and `message.dispatch`, which `client.turn.requested` already counts, are - * left out. + * Exclude housekeeping. `client.turn.requested` counts every `message.dispatch`; only sending a + * guest's message is also a feature. Record at command dispatch so retries do not count twice. */ -const COMMAND_FEATURES: Partial< - Record FeatureUsage> -> = { +const COMMAND_FEATURES: { + readonly [Type in OrchestrationV2Command["type"]]?: ( + command: Extract, + ) => FeatureUsage | undefined; +} = { + "message.dispatch": (command) => + command.guestMessageId === undefined ? undefined : { feature: "guest_message.send" }, "checkpoint.rollback": (command) => ({ feature: "checkpoint.rollback", variant: command.restoreFiles === false ? "conversation_only" : "with_files", @@ -38,30 +36,24 @@ const COMMAND_FEATURES: Partial< "thread.pin": () => ({ feature: "thread.pin" }), "thread.settle": () => ({ feature: "thread.settle" }), "thread.pull-request.link": () => ({ feature: "thread.pull_request.link" }), - "thread.pull-request.watch": () => ({ feature: "thread.pull_request.watch" }), - "thread.runtime-mode.set": (command) => - use("thread.runtime_mode.set", literal(command, "runtimeMode")), + "thread.pull-request.watch": (command) => + use("thread.pull_request.watch", command.watching ? "on" : "off"), + "thread.runtime-mode.set": (command) => use("thread.runtime_mode.set", command.runtimeMode), "thread.interaction-mode.set": (command) => - use("thread.interaction_mode.set", literal(command, "interactionMode")), + use("thread.interaction_mode.set", command.interactionMode), "run.interrupt": () => ({ feature: "run.interrupt" }), "queued-message.promote-to-steer": () => ({ feature: "queue.steer" }), "queued-run.edit": () => ({ feature: "queue.edit" }), + "thread.guest-share.close": () => ({ feature: "guest_share.close" }), + "thread.guest-message.dismiss": () => ({ feature: "guest_message.dismiss" }), }; /** * RPCs that represent a user starting a feature. Variants come only from * closed enums in the contracts, so no path, id, or free text can reach analytics. + * Thread launches are left out because `client.thread.started` counts them. */ const METHOD_FEATURES: Readonly FeatureUsage>> = { - [ORCHESTRATION_V2_WS_METHODS.launchThread]: (payload) => { - const strategy = payload.workspaceStrategy; - return use( - "thread.launch", - typeof strategy === "object" && strategy !== null - ? literal(strategy as Payload, "type") - : undefined, - ); - }, [WS_METHODS.terminalOpen]: () => ({ feature: "terminal.open" }), [WS_METHODS.previewOpen]: () => ({ feature: "preview.open" }), [WS_METHODS.deviceOpen]: () => ({ feature: "device.open" }), @@ -79,15 +71,20 @@ const METHOD_FEATURES: Readonly FeatureUsag [WS_METHODS.projectCloneStart]: () => ({ feature: "project.clone" }), [WS_METHODS.sourceControlPublishRepository]: () => ({ feature: "repository.publish" }), [WS_METHODS.serverUpsertKeybinding]: () => ({ feature: "keybinding.customize" }), + [WS_METHODS.guestShareCreate]: () => ({ feature: "guest_share.create" }), }; +/** The feature a dispatched command uses, or undefined when it is not a tracked feature. */ +export function commandFeatureUsage(command: OrchestrationV2Command): FeatureUsage | undefined { + // TypeScript cannot pair a union's tag with its own entry, so widen once here. + const mapping = COMMAND_FEATURES[command.type] as + | ((command: OrchestrationV2Command) => FeatureUsage | undefined) + | undefined; + return mapping?.(command); +} + /** The feature a WebSocket request uses, or undefined when it is not a tracked feature. */ export function featureUsage(method: string, payload: unknown): FeatureUsage | undefined { if (typeof payload !== "object" || payload === null) return undefined; - const input = payload as Payload; - if (method === ORCHESTRATION_V2_WS_METHODS.dispatchCommand) { - const type = literal(input, "type") as OrchestrationV2Command["type"] | undefined; - return type === undefined ? undefined : COMMAND_FEATURES[type]?.(input); - } - return METHOD_FEATURES[method]?.(input); + return METHOD_FEATURES[method]?.(payload as Payload); } diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 05e657adbe347..0baebc1e1803d 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -271,15 +271,10 @@ const readProviderLogins = Effect.fn("TelemetryIdentity.readProviderLogins")(fun }); /** - * `distinctId` keeps the rule every earlier build used, so analytics stay - * continuous: the hashed Codex `account_id`, else the Claude machine `userID`, - * else the install's anonymous id. It can change when someone logs in, and a - * Codex Team shares one. `installId` is the hashed anonymous id, which never - * changes for an install; count installs and people with it. - * - * A null `distinctId` turns telemetry off. A missing file falls through quietly, - * and so does an API-key-only Codex auth.json. Only unreadable or malformed - * files warn. + * `distinctId` preserves legacy priority: hashed Codex account_id, Claude userID, then anonymous id. + * It can change on login or be shared by a Codex Team; hashed `installId` stays stable per install. + * A null `distinctId` disables telemetry. Missing files and API-key-only Codex auth fall through; + * only unreadable or malformed files warn. */ export const getTelemetryIdentityForHome = Effect.fn("getTelemetryIdentityForHome")(function* ( homeDirectory: string, diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 49ee2db831a94..acff38ef00c37 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -826,7 +826,7 @@ it.layer( "done\n"; // Counts reads of strings larger than one emitted chunk, so rescanning the - // pending prefix on every chunk (#23817) shows up as quadratic work. + // pending prefix on every chunk shows up as quadratic work. const charCodeAt = String.prototype.charCodeAt; let largeStringReads = 0; yield* Effect.gen(function* () { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9f3c6387834cd..5476e7964a46c 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -189,16 +189,12 @@ export class TerminalManager extends Context.Service< readonly clear: (input: TerminalClearInput) => Effect.Effect; - /** - * Always resets history before spawning the new process. - */ + /** Resets history before spawning. */ readonly restart: ( input: TerminalRestartInput, ) => Effect.Effect; - /** - * When `terminalId` is omitted, closes all sessions for the thread. - */ + /** Omit terminalId to close all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; readonly closeIdle: (input: { @@ -662,8 +658,7 @@ function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { const childrenByParent = new Map(); const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { - // `comm=` is the final column and may itself contain spaces, so only the - // first two tokens are structural. + // The final comm column can contain spaces; only the first two tokens are structural. const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); if (!match) continue; const pid = Number(match[1]); @@ -701,8 +696,7 @@ function deriveSubprocessInspectResult( const commandName = (pid: number) => normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); const shellName = commandName(terminalPid); - // Async prompt themes fork the shell into a helper that waits with no - // children of its own. That copy is not a command the user started. + // Async prompt themes fork childless shell helpers that are not user commands. const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( (pid) => shellName === null || @@ -733,9 +727,7 @@ function deriveSubprocessInspectResult( const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; -// Resolve `ps` to an absolute path once at startup. Spawning by bare name -// walks every PATH entry per spawn (one failed posix_spawn per directory -// until the hit), which is measurable at a 1s poll cadence on long PATHs. +// Resolve ps once; bare-name spawns repeatedly probe long PATHs during one-second polling. const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { const fileSystem = yield* FileSystem.FileSystem; for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { @@ -772,8 +764,7 @@ const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot" ), ); if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { - // Not authoritative: an empty or partial table would mark every terminal - // idle and clear its registered process ids. Failing skips the tick. + // Reject partial tables; treating them as authoritative would clear process ids and mark terminals idle. return yield* new TerminalSubprocessCheckError({ command: "ps", exitCode: result.code, @@ -1031,7 +1022,6 @@ function neutralizeInheritedHistory(history: string, maxLines: number, maxBytes: // An unconditional 1049 reset restores a stale saved cursor on the main // screen. The exit must match the inherited screen's cursor-save behavior. let alternateScreen: number | undefined; - // eslint-disable-next-line no-control-regex -- Match replayed alternate-screen controls. for (const match of history.matchAll(/\u001b(?:\[\?([0-9;]+)([hl])|(c))/gu)) { if (match[3]) alternateScreen = undefined; for (const parameter of (match[1] ?? "").split(";")) { @@ -1062,10 +1052,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) { return true; } - // DECRQM mode queries (…$p) and DECRPM replies (…$y): replaying a stored - // query makes the terminal answer again, and the shell echoes the answer as - // junk at the prompt. The `$` guard keeps setters like DECSTR (!p) and - // DECSCL ("p) intact. + // Strip DECRQM/DECRPM query traffic to prevent echoed replies during replay; $ guards preserve setters. if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) { return true; } @@ -1080,9 +1067,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean { return false; } -// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): -// pure request/response traffic with no visual value, and replaying a stored -// query triggers a fresh reply. +// Strip DECRQSS/XTGETTCAP query traffic; replaying queries triggers fresh replies without visual value. function shouldStripDcsSequence(content: string): boolean { return /^[01]?[$+][qr]/.test(content); } @@ -1134,7 +1119,6 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; } -// eslint-disable-next-line no-control-regex -- Terminal sequence introducers must reach the parser. const HISTORY_SEQUENCE_START = /[\u001b\u0090\u009b\u009d\u009e\u009f]/; const NEXT_HISTORY_SEQUENCE_START = new RegExp(HISTORY_SEQUENCE_START.source, "g"); @@ -1319,12 +1303,7 @@ function isPathSegmentUnderAppDir(segment: string, appDir: string): boolean { return segment === appDir || segment.startsWith(`${appDir}/`); } -// On Linux AppImage builds the runtime mounts the app under a temporary dir and -// injects APPIMAGE/APPDIR/ARGV0/OWD plus mount entries on PATH/LD_LIBRARY_PATH. -// The integrated terminal inherits the server process environment, so without -// this scrub those leak into the PTY and tools resolve against the AppImage -// mount instead of the user's real environment (e.g. `php` reporting PHP_BINARY -// as the AppImage path). See issue #1699. +// AppImage mount paths would make PTY tools resolve against the mount instead of the user's system. function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (env.APPIMAGE === undefined && env.APPDIR === undefined) return env; @@ -1476,7 +1455,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir, providerStatusCacheDir, baseDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; @@ -1524,9 +1503,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const platform = yield* HostProcess.Platform; const architecture = yield* HostProcess.Architecture; const agentScope = yield* AgentScope; - // Terminals must inherit the user's full environment (minus the blocklist - // applied in createTerminalSpawnEnv) — an allowlist here silently strips - // things like PSModulePath, DISPLAY, proxies, and toolchain variables. + // Inherit the full environment minus createTerminalSpawnEnv's blocklist; allowlists silently drop user tooling variables. const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; @@ -1548,9 +1525,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return { ...input, env }; }, ); - // One process-table snapshot per poll tick, shared across every terminal. - // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and - // can exhaust the PID space on hosts with many sessions (#6332). + // Share one process table per poll; per-terminal scans can exhaust PIDs on busy hosts. const fallbackProcessTableSnapshot = ( platform === "win32" ? windowsProcessTableSnapshot() @@ -1691,8 +1666,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func fiber: Fiber.Fiber, ) { yield* modifyManagerState((state) => { - // The child may finish (and run its finalizer) before this registration. - // Check inside the synchronized update so completed fibers are not retained. + // Children may finalize before registration; check inside the synchronized update to avoid retaining finished fibers. if (fiber.pollUnsafe() !== undefined) { return [undefined, state] as const; } @@ -1707,8 +1681,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func threadId: string, terminalId: string, ) { - // Subscribe before SIGTERM so an immediate exit is not missed. Released with - // Effect.ensuring because a JS `finally` does not run when the fiber is interrupted. + // Subscribe before SIGTERM to catch immediate exits; Effect.ensuring releases even on interruption, unlike JS finally. const exited = Deferred.makeUnsafe(); const unsubscribeExit = process.onExit(() => { Deferred.doneUnsafe(exited, Effect.void); @@ -2333,9 +2306,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func platform, yield* HostProcess.HomeDirectory, ); - // Append (never prepend) managed ACP agent install directories so - // `kimi login` and friends resolve by name without shadowing any - // system or user tool of the same name. + // Append managed install directories so agent commands resolve without shadowing user/system tools. if ( options.managedBinaryCacheDir !== undefined && options.managedBinaryToolsDir !== undefined @@ -3345,8 +3316,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const marks = new Map( running.map((session) => [session.terminalId, terminalActivityMark(session)]), ); - // Inspect now instead of trusting the last poll, so a command started - // since then keeps its terminal. + // Inspect now so commands started after the last poll still protect their terminals. const { inspector } = yield* acquireSubprocessInspector; yield* Effect.forEach( running, diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index 047c24ae8fc3d..4b83ddf6697e6 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -26,10 +26,7 @@ export class NodePtyModuleLoadError extends Schema.TaggedError Promise; -// node-pty stays external to the CLI bundle because it dlopens a native -// addon. Inside a Node single-executable, `import()` cannot load files from -// disk (only built-ins resolve), while `require` always reads the real -// filesystem, so both the module and its spawn-helper resolve through it. +// node-pty's native addon stays external; single-executables need require to resolve disk modules and spawn helpers. const requireForNodePty = NodeModule.createRequire(import.meta.url); const loadNodePty: NodePtyModuleLoader = () => @@ -81,12 +78,8 @@ const ensureNodePtySpawnHelperExecutable = Effect.fn(function* () { yield* fs.chmod(helperPath, 0o755).pipe(Effect.orElseSucceed(() => undefined)); }); -/** - * node-pty defers creation to avoid blocking on named pipes: - * https://github.com/microsoft/node-pty/pull/885 Its public API has no - * readiness event. The private ready_datapipe handler sets pid before our - * listener runs. - */ +// node-pty has no public readiness event; ready_datapipe sets pid before our listener. +// Deferred pipe creation: https://github.com/microsoft/node-pty/pull/885 const waitForWindowsPid = ( process: import("node-pty").IPty, trackedProcess: NodePtyProcess, @@ -139,10 +132,7 @@ const waitForWindowsPid = ( return Effect.sync(cleanup); }); -/** - * Cancels Windows startup without waiting for the first output, unlike public kill(). - * The private agent can cancel the pending connection before a child exists. - */ +// The private agent cancels pending connections before a child exists; public kill waits for first output. const killStartingWindowsPty = (process: import("node-pty").IPty) => Effect.try(() => { if ( @@ -255,9 +245,7 @@ const make = Effect.fn("NodePtyAdapter.make")(function* () { return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; - // node-pty only writes `name` into the child's TERM on the Unix path; - // the ConPTY path leaves the environment untouched, so Windows children - // inherit a missing or 16-color TERM unless it is set here. + // ConPTY ignores node-pty's name option for TERM; set it explicitly to avoid missing/16-color values. const env = platform === "win32" && input.env["TERM"] === undefined ? { ...input.env, TERM: "xterm-256color" } diff --git a/apps/server/src/terminal/nodePtyConsoleList.test.ts b/apps/server/src/terminal/nodePtyConsoleList.test.ts index be538491877c2..bcc8313089f43 100644 --- a/apps/server/src/terminal/nodePtyConsoleList.test.ts +++ b/apps/server/src/terminal/nodePtyConsoleList.test.ts @@ -3,9 +3,7 @@ import * as NodeModule from "node:module"; import { afterEach, describe, expect, it } from "vite-plus/test"; -// Exercises the patched console-list helper in patches/node-pty@1.2.0-beta.15.patch. -// The agent calls `require("child_process").fork` at call time, so replacing it on -// the shared CJS module stands in for the forked helper on every platform. +// The agent forks via the shared CJS module at call time; replace it to stub the helper on every platform. const requireCjs = NodeModule.createRequire(import.meta.url); const childProcess: typeof import("node:child_process") = requireCjs("child_process"); const { diff --git a/apps/server/src/testUtils/gitConfig.setup.ts b/apps/server/src/testUtils/gitConfig.setup.ts index a8139563ff3bf..f2ad4a83e2d54 100644 --- a/apps/server/src/testUtils/gitConfig.setup.ts +++ b/apps/server/src/testUtils/gitConfig.setup.ts @@ -1,8 +1,4 @@ -// Git for Windows installs with core.autocrlf=true, which checks committed LF -// files out as CRLF and breaks every byte-exact content assertion; a signing -// key or a non-default init branch on the developer's machine breaks fixtures -// the same way. Set as environment so each git child the driver spawns sees it -// without touching the fixtures. +// Override inherited autocrlf, signing and init-branch settings through env so every git child gets stable fixtures. const entries: ReadonlyArray = [ ["core.autocrlf", "false"], ["core.filemode", "false"], diff --git a/apps/server/src/testUtils/managedServiceEnv.setup.ts b/apps/server/src/testUtils/managedServiceEnv.setup.ts new file mode 100644 index 0000000000000..a6581817e14bb --- /dev/null +++ b/apps/server/src/testUtils/managedServiceEnv.setup.ts @@ -0,0 +1,6 @@ +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "../cloud/serviceProtocol.ts"; + +// Agents inside a managed T3 service inherit its home and launcher context; CI has neither. +for (const name of ["T3CODE_HOME", "T3_BOOT_SERVICE_UNIT", SERVICE_LAUNCHER_CONTEXT_ENV]) { + delete process.env[name]; +} diff --git a/apps/server/src/testUtils/weightedShardSequencer.ts b/apps/server/src/testUtils/weightedShardSequencer.ts index 6aed9892443d5..51c5130b3a5c4 100644 --- a/apps/server/src/testUtils/weightedShardSequencer.ts +++ b/apps/server/src/testUtils/weightedShardSequencer.ts @@ -6,21 +6,12 @@ import { BaseSequencer, type TestSpecification } from "vite-plus/test/node"; import shardWeights from "./shardWeights.json" with { type: "json" }; -// The median recorded file, for files added since the weights were recorded. +// Use the recorded median for new files. const UNRECORDED_FILE_SECONDS = 0.6; const recordedSeconds: Readonly> = shardWeights; -/** - * Splits server test files across `--shard` runs by recorded duration. Vitest's - * default split hashes file paths into equal-count shards, which can put the - * slowest files on one runner. Here the longest file goes to the lightest shard - * first. Every shard derives the same split from the same file list, so each - * file still runs in exactly one shard. Weights are each file's wall time in a - * CI shard, imports included: the gaps between consecutive file results in the - * `Test Server` logs. Imports are about a third of a shard, so test time alone - * leaves the shards with many small files slowest. - */ +// Include import time (~1/3 of CI shards) in weights; derive identical splits so each file runs once. export class WeightedShardSequencer extends BaseSequencer { override async shard(files: TestSpecification[]) { const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 }; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index b20e07e65e1b2..8fa9ccb1c8094 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -35,9 +35,9 @@ import { } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; -/** The tools an investigation can see. `--tools` takes bare names; patterns go in the allow list. */ +/** `--tools` takes bare names; patterns go in the allow list. */ const CLAUDE_INVESTIGATION_TOOLS = "Read,Grep,Glob,Bash"; -/** What it may run without asking. In dontAsk mode every other call, Bash included, is refused. */ +/** In dontAsk mode every call outside this list, Bash included, is refused. */ const CLAUDE_INVESTIGATION_ALLOWED = [ "Read", "Grep", @@ -46,10 +46,7 @@ const CLAUDE_INVESTIGATION_ALLOWED = [ "Bash(git log:*)", "Bash(git show:*)", ].join(","); -/** - * The allowed git commands accept any trailing arguments, and `--output` makes each of them write - * a file anywhere. Deny rules win over allow rules, so this keeps the investigation read-only. - */ +// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere. const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(","); const ClaudeOutputEnvelope = Schema.Struct({ @@ -70,10 +67,7 @@ const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result const isClaudeErrorResult = Schema.is(ClaudeErrorResult); const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); -/** - * The error a failed `claude -p --output-format json` run reports on stdout. Stderr may only - * hold notices from a wrapper around the binary, so the caller prefers this when present. - */ +// Claude reports JSON errors on stdout; stderr may contain only wrapper notices. function claudeErrorResult(stdout: string): string | undefined { const output = Option.getOrUndefined(decodeJsonOption(stdout)); const messages: ReadonlyArray = Array.isArray(output) ? output : [output]; @@ -223,8 +217,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", - // Claude refuses this flag next to a managed-mcp.json, which already keeps the - // user's own MCP servers out. + // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", "dontAsk", diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b57ed29988c76..a83e09a4110df 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -351,9 +351,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { }, (textGeneration) => Effect.gen(function* () { - // Background fibers (e.g. the worktree branch rename fork) can run - // after their launching request's scope has closed; temp files must - // not be tied to that ambient scope or they are reaped on creation. + // Background fibers can outlive request scopes; temp files scoped there would be reaped on creation. const closedScope = yield* Scope.make(); yield* Scope.close(closedScope, Exit.void); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 20066232105f0..e17fa4c4c00e7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -30,9 +30,7 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; const CODEX_TIMEOUT_MS = 180_000; const CODEX_MCP_LIST_TIMEOUT_MS = 10_000; -// Plugins and apps bring MCP servers of their own; the user's are listed and -// turned off by name below. These follow the user's launch args, so they win -// over a `-c features.plugins=true` there, which `--disable plugins` does not. +// Plugins/apps add MCP servers; use trailing config overrides because --disable loses to user -c flags. const CODEX_NO_PLUGIN_ARGS = [ "--config", "features.plugins=false", @@ -44,10 +42,7 @@ const CodexMcpServers = Schema.fromJsonString( ); const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers); -/** - * `mcp_servers={}` merges into the user's servers rather than replacing them, - * so each is named; quoting the name keeps one with a dot a single key. - */ +// mcp_servers={} merges instead of replacing; disable named entries and quote dotted names. const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null @@ -85,13 +80,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const removeTempFileDir = (filePath: string): Effect.Effect => - fileSystem - .remove(path.dirname(filePath), { recursive: true }) - .pipe(Effect.catch(() => Effect.void)); + fileSystem.remove(path.dirname(filePath), { recursive: true }).pipe(Effect.ignore); - // Deliberately unscoped: text generation runs from background fibers whose - // ambient scope may already be closed (a closed scope reaps the temp - // directory the moment it is created). + // Background fibers may inherit closed scopes that immediately reap temp files; write unscoped. const writeTempFile = ( operation: string, prefix: string, @@ -290,9 +281,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func [ "exec", ...execLaunchArgs, - // Text generation needs only the prompt. A user's MCP server would - // otherwise start and could act on it, as Claude's --strict-mcp-config - // prevents for Claude. + // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, ...(mcpServersOff ? ["--config", mcpServersOff] : []), "--ephemeral", diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts index d45aad1d34300..5840b909acbd5 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.fixture.ts @@ -1,7 +1,4 @@ -/** - * (A deny-all session is refused on the free tier: the spike's - * `text_generation` recording.) - */ +/** The free tier refuses deny-all sessions. */ import type { ProviderReplayEntry } from "@t3tools/contracts"; export const OPENCODE2_TITLE_GENERATION: ReadonlyArray = [ diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 7e2d7bfebef41..96f266c6f8959 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -52,29 +52,35 @@ const resolveInstance = ( ), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; return TextGeneration.of({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) { + return resolveInstance( + registry, + "generateCommitMessage", + input.modelSelection.instanceId, + ).pipe(Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input))); + }), + generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) { + return resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + ); + }), + generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) { + return resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), - ), - generateReviewGuide: (input) => - resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( + ); + }), + generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) { + return resolveInstance(registry, "generateReviewGuide", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateReviewGuide(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + ); + }), + generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) { + return resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => Effect.gen(function* () { const linkedContext = @@ -88,7 +94,8 @@ export const make = Effect.gen(function* () { return yield* textGeneration.generateThreadTitle({ ...input, linkedContext }); }), ), - ), + ); + }), }); }); diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index 0ba4ecf49b9ab..7a068cde85f6c 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -46,7 +46,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* Settings.ServerSettingsService; @@ -83,9 +83,7 @@ export const make = Effect.gen(function* () { if (changed) yield* PubSub.publish(changes, next); }); - // One refresh at a time: a slow hub read started before a settings change - // must not publish after the change's own refresh and resurrect a removed - // source. Callers queue behind the in-flight run and see current settings. + // Serialize refreshes so slow old-settings reads cannot resurrect removed sources after newer refreshes. const refreshLock = yield* Semaphore.make(1); const refresh = Effect.gen(function* () { const settings = yield* settingsService.getSettings.pipe( diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a7256aabef2b9..4cd1143ac3787 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -199,9 +199,7 @@ const NARROW_WINDOW: UsageSummaryInput = { untilDay: UsageDay.make("2026-09-02"), }; -/** - * Each waiting gate holds one libuv pool thread; keep at most three. - */ +// Each waiting gate holds a libuv thread; allow at most three. const makeGate = (path: string, lastWriteSeconds?: number) => Effect.promise(async () => { NodeChildProcess.execFileSync("mkfifo", [path]); @@ -2612,6 +2610,7 @@ describe("UsageService", () => { ); let orphanedAt: number | undefined; + // oxlint-disable-next-line t3code/no-polling-loop-in-tests -- Fixed-count scheduler interruption matrix; each iteration exercises a different interruption point. for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { const tasks: Array<() => void> = []; const dispatcher: Scheduler.SchedulerDispatcher = { diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 831a0b1997e7b..78dd32139dc13 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -67,10 +67,7 @@ const RATES_TTL_MS = 24 * 60 * 60 * 1000; const RATES_REFRESH_FLOOR_MS = 60 * 1000; -/** - * Files are filtered by mtime before opening. The slack covers a session whose - * last write lands just before local midnight on the window's first day. - */ +// mtime slack includes writes just before local midnight on the window's first day. const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -124,11 +121,7 @@ const sharedSessionProviders: ReadonlySet = new Set( ), ); -/** - * Only these need cross-file dedupe keys: within one file the occurrence count - * already keeps every key unique, so keying the rest would only build and hash - * a string for each of their records. - */ +// Only shared sessions need cross-file dedupe; per-file occurrence counts make other keys unique. function sharedSessions( files: readonly { readonly records: readonly UsageRecord[] }[], ): ReadonlySet { @@ -159,32 +152,6 @@ export class UsageService extends Context.Service< } >()("t3/usage/UsageService") {} -const EMPTY_PRICING: UsagePricing = { - status: "unavailable", - source: LITELLM_RATES_URL, - fetchedAt: null, - knownModels: 0, -}; - -const layerTest = Layer.succeed( - UsageService, - UsageService.of({ - readSummary: (input) => - Effect.succeed({ - contractVersion: USAGE_CONTRACT_VERSION, - readAt: "1970-01-01T00:00:00.000Z", - timeZone: input.timeZone, - sinceDay: input.sinceDay, - untilDay: input.untilDay, - buckets: [], - sources: [], - pricing: EMPTY_PRICING, - scanDurationMs: 0, - }), - refreshRates: Effect.succeed(EMPTY_PRICING), - }), -); - export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -383,11 +350,7 @@ export const make = Effect.gen(function* () { return dirs; }); - /** - * `Effect.cached` makes concurrent first readers await the same load rather - * than each seeing a "loaded" flag set before the read finished and cold - * scanning against an empty cache. - */ + // Share first loads so concurrent readers cannot scan against a still-empty cache. const ensureScanCacheLoaded = yield* Effect.cached( Effect.gen(function* () { const readDocument = (filePath: string) => @@ -414,13 +377,10 @@ export const make = Effect.gen(function* () { ); const writeScanCache = makeScanCacheWriter(); - // Scans with different windows can finish together; serializing the writes - // keeps an older snapshot from landing after a newer one. + // Serialize writes so concurrently finished window scans cannot overwrite newer snapshots. const persistLock = yield* Semaphore.make(1); - // The dirty flag is cleared before encoding, so a change while the write is - // in flight marks it dirty again. A failed write restores the flag, so the - // next persist retries instead of leaving disk stale. + // Clear dirty before encoding to retain concurrent changes; restore it on failure for retry. const persistCaches = Effect.gen(function* () { if (cacheDirty) { cacheDirty = false; @@ -447,8 +407,6 @@ export const make = Effect.gen(function* () { const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( Effect.asVoid, ); - // A write still running when the service shuts down finishes first, so the - // next start does not lose the last scan. yield* Effect.addFinalizer(() => awaitPersisted); /** @@ -639,36 +597,31 @@ export const make = Effect.gen(function* () { const scans = Effect.forEach( scanReaders, ({ driver, reader }) => - usageInstances(driver, settings) - .pipe( - Effect.flatMap((instances) => - reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), - ), - ) - .pipe( - Effect.flatMap((sources) => - Effect.forEach(sources, ({ volumeId, ...source }) => - Effect.map( - volumeId === undefined - ? Effect.promise(() => readDirectoryVolumeId(source.dir)) - : Effect.succeed(volumeId), - (resolved): ScannedDir => ({ - ...source, - files: source.files?.map((file) => ({ ...file, malformedRecords: 0 })) ?? null, - provider: reader.provider, - volumeId: resolved, - }), - ), + usageInstances(driver, settings).pipe( + Effect.flatMap((instances) => + reader.scan({ instances, settings, windowStartMs, retentionCutoffMs, awaitRefresh }), + ), + Effect.flatMap((sources) => + Effect.forEach(sources, ({ volumeId, ...source }) => + Effect.map( + volumeId === undefined + ? Effect.promise(() => readDirectoryVolumeId(source.dir)) + : Effect.succeed(volumeId), + (resolved): ScannedDir => ({ + ...source, + files: source.files?.map((file) => ({ ...file, malformedRecords: 0 })) ?? null, + provider: reader.provider, + volumeId: resolved, + }), ), ), - Effect.provideContext(readerContext), ), + Effect.provideContext(readerContext), + ), { concurrency: "unbounded" }, ); - // Independent sources scan together. Transcript directories go one at a - // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result - // keeps this order, since aggregation keeps the first copy of a duplicate. + // Scan independent sources together, directories sequentially to bound open files; preserve order for first-copy dedupe. const [transcripts, scanDirs] = yield* Effect.all( [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, @@ -749,8 +702,7 @@ export const make = Effect.gen(function* () { const sources: UsageSource[] = []; - // Cleanup may remove transcripts, but the usage we already saved still - // contributes to its source through the normal aggregation and dedupe path. + // Deleted transcripts' saved usage still aggregates and deduplicates normally. const retainedSinceMs = Math.max(windowStartMs, retentionCutoffMs); const filesByDir = scannedDirs.map(({ provider, dir, files }) => { const retainedFiles = [...(files ?? [])]; @@ -842,8 +794,7 @@ export const make = Effect.gen(function* () { sharedSessionProviders.has(record.provider) && crossFileSessions.has(record.sessionId) ) { - // Match moved copies without collapsing repeated equal events - // within one file (timestamps can have only second precision). + // Dedupe moved copies but retain repeated equal events within a file, including second-precision timestamps. const key = encodeUsageRecordKey([ record.provider, record.sessionId, @@ -933,14 +884,11 @@ export const make = Effect.gen(function* () { const existing = inflightScans.get(key); if (existing !== undefined) return existing; - // Enrollment and detached-fiber creation must be atomic. Otherwise a - // canceled first caller can leave a Deferred with no scan to finish it. + // Register and fork atomically so cancellation cannot leave a Deferred without a scan. const created = Deferred.makeUnsafe(); inflightScans.set(key, created); - // Detached so one departing client cannot tear the scan out from under - // the fibers awaiting it; a finished scan warms the cache either way. - // The cache write is registered before the waiters resume, so they - // can await it, but its fiber starts after they have the summary. + // Detach so caller departure cannot cancel shared scans; finished scans warm the cache. + // Register writes before releasing waiters, but start writing after delivering their summary. yield* scanSummary(input, settings).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index bca612a044f4b..0119adfd940f3 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -119,7 +119,6 @@ function fixture( }; } -/** A hub API whose every request fails the way `fail` says. */ const unreachableHub = ( fail: ( request: Parameters[0]>[0], diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 4c981b96dfb6d..91504d8002295 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -91,8 +91,7 @@ const decodeClaudeUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(Claud const decodeCodexUsage = Schema.decodeUnknownEffect(Schema.fromJsonString(CodexUsage)); const isUsageLimitSourceError = Schema.is(UsageLimitSourceError); -// Codes that mean the certificate chain is not trusted, which an extra CA can fix. Expired or -// mismatched certificates are reported by code only. +// Extra CAs can fix untrusted chains; expiry/hostname failures report codes only. const UNTRUSTED_CERTIFICATE_CODES = new Set([ "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", @@ -100,7 +99,7 @@ const UNTRUSTED_CERTIFICATE_CODES = new Set([ "UNABLE_TO_VERIFY_LEAF_SIGNATURE", ]); -/** The Node error code under a failed fetch, which wraps it in a generic `TypeError`. */ +// fetch wraps Node error codes in generic TypeErrors. const nodeErrorCode = (cause: unknown) => { for (let error = cause, depth = 0; error instanceof Error && depth < 3; depth += 1) { const code = (error as { readonly code?: unknown }).code; @@ -110,8 +109,7 @@ const nodeErrorCode = (cause: unknown) => { return undefined; }; -// Hub responses and nested error messages can carry keys or tokens, so only the failure's kind, -// HTTP status, or Node error code reaches the client. +// Hub responses can contain credentials; expose only failure kind, HTTP status or Node code. const describeManagementFailure = (error: unknown) => { if (Cause.isTimeoutError(error)) return "The hub did not answer within 15 seconds."; if (HttpClientError.isHttpClientError(error)) { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 65e28d4845a72..17903cd062eae 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -10,10 +10,7 @@ import type { import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; -/** - * `en-CA` yields ISO-ordered parts, which is why it is used here rather than - * assembling the day from `Date` getters (those are host-local only). - */ +// en-CA gives ISO-ordered parts in the requested zone; Date getters are host-local. function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; try { @@ -31,10 +28,7 @@ function makeDayFormatter(timeZone: string): (timestampMs: number) => string { day: "2-digit", }); } - // Formatting once per quarter-hour slot keeps `Intl` (about 2µs a call) off - // the per-record path. Named zones put midnight on a quarter hour, so their - // slots never split; a fixed offset such as `+00:01` can, and records in a - // split slot are formatted exactly. + // Cache quarter-hour slots to avoid per-record Intl calls; fixed-offset midnight can split slots and needs exact formatting. const days = new Map(); return (timestampMs) => { const slot = Math.floor(timestampMs / QUARTER_HOUR_MS); @@ -100,11 +94,7 @@ export interface AggregateResult { readonly outOfWindow: number; } -/** - * De-duplication is global across the whole scan, not per file: Claude Code - * copies a message's records forward when a session is resumed or forked, so - * the same `dedupeKey` legitimately appears in several transcripts. - */ +/** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ export class UsageAggregator { readonly #buckets = new Map(); readonly #seen = new Set(); @@ -209,7 +199,7 @@ export class UsageAggregator { return true; } - /** The target's own rate applies, so a provider-specific `rateModel` is dropped. */ + // Aliases use the target's rate, replacing provider-specific rateModel. #mapModel(record: UsageRecord): UsageRecord { const model = this.#options.modelAliases?.get(record.model); if (model === undefined) return record; @@ -217,11 +207,7 @@ export class UsageAggregator { return { ...rest, model }; } - /** - * Records arrive file by file in time order, so most land in the bucket the - * previous record used. Checking that first skips building and hashing a - * key string per record. - */ + // Time-ordered records usually reuse the previous bucket; avoid rebuilding its key. #bucketFor( day: string, hourIndex: number, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 3b8b62d48a6ee..2ce6d6f74eff7 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -233,6 +233,21 @@ describe("usage pricing", () => { ); }); + it("keeps a bare alias when a reseller entry omits the fast tier", () => { + const table = parseRateTable({ + "provider-a/example-model": { + ...rate(1), + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 10, + }, + "provider-b/example-model": rate(1), + }); + + expect(lookupRate(table, "example-model")).toEqual( + lookupRate(table, "provider-a/example-model"), + ); + }); + it("leaves an ambiguous bare name unpriced", () => { const table = parseRateTable({ "provider-a/example-model": rate(1), diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 50d839f8175c4..fa512218d7c0c 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -14,10 +14,7 @@ export interface TokenRates { readonly cacheCreationCostPerToken: number; } -/** - * LiteLLM also publishes `*_above_272k_tokens`, `*_flex`, and `*_batches` variants. Transcripts - * don't record those, so we don't price them. - */ +/** Omits LiteLLM's above_272k_tokens, flex and batches variants, which transcripts do not record. */ export interface ModelRate extends TokenRates { readonly fast: TokenRates | null; readonly ultrafast: TokenRates | null; @@ -51,10 +48,7 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -/** - * Anthropic bills cache reads at a discount and cache writes at a premium. When the standard - * tier omits them, cached input is priced as plain input rather than as free. - */ +// Missing cache rates fall back to input pricing; Anthropic normally discounts reads and charges extra for writes. function readTokenRates( entry: LiteLlmEntry, suffix: string, @@ -95,10 +89,7 @@ function fastMultiplier(entry: LiteLlmEntry): number | null { return fast !== null && fast > 0 ? fast : null; } -/** - * Entries without both an input and an output rate are dropped: a half-priced model would - * silently under-report cost, which is worse than reporting the model as unpriced. - */ +/** Drops models missing input or output rates to avoid understating cost. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); if (typeof document !== "object" || document === null) return table; @@ -129,8 +120,8 @@ export function parseRateTable(document: unknown): RateTable { const held = aliasCandidates.get(alias); if (held === undefined) { aliasCandidates.set(alias, rate); - } else if (held !== null && !sameRate(held, rate)) { - aliasCandidates.set(alias, null); + } else if (held !== null) { + aliasCandidates.set(alias, mergeAliasRate(held, rate)); } } for (const [alias, rate] of aliasCandidates) { @@ -150,12 +141,18 @@ function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean { ); } -function sameRate(a: ModelRate, b: ModelRate): boolean { - return ( - sameTokenRates(a, b) && - sameTokenRates(a.fast, b.fast) && - sameTokenRates(a.ultrafast, b.ultrafast) - ); +// Resellers often omit speed tiers, so a missing tier fills from the other entry instead of conflicting. +function mergeAliasRate(a: ModelRate, b: ModelRate): ModelRate | null { + const fast = a.fast ?? b.fast; + const ultrafast = a.ultrafast ?? b.ultrafast; + if ( + !sameTokenRates(a, b) || + !sameTokenRates(fast, b.fast ?? fast) || + !sameTokenRates(ultrafast, b.ultrafast ?? ultrafast) + ) { + return null; + } + return { ...a, fast, ultrafast }; } function ratesAt(rate: ModelRate, speed: UsageSpeed): TokenRates { @@ -171,21 +168,13 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } -/** - * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which - * Claude Code writes for the 1M context tier. The rate table only knows the - * base name, and we price at the base tier anyway. - */ +// Price Claude's bracketed context variants at the base tier, the only name the rate table knows. function stripVariantSuffix(key: string): string { const bracket = key.indexOf("["); return bracket === -1 ? key : key.slice(0, bracket); } -/** - * `` marks locally generated messages that were never billed. Bare family names - * ("opus", "sonnet") are genuinely ambiguous across generations, so we report them as unpriced - * instead of guessing a generation. - */ +// Synthetic messages are unbilled; bare family names are generation-ambiguous and remain unpriced. const UNPRICEABLE_MODELS = new Set([ "", "synthetic", @@ -195,10 +184,7 @@ const UNPRICEABLE_MODELS = new Set([ "fable", ]); -/** - * A scan prices every record twice against a few dozen models, and tables are - * never mutated once built. - */ +// Immutable tables permit caching rates across twice-per-record pricing lookups. const resolvedRates = new WeakMap>(); export function lookupRate(table: RateTable, model: string): ModelRate | null { @@ -247,10 +233,7 @@ function sumCategories(cost: UsageCategoryCost): number { return cost.input + cost.cacheRead + cost.cacheWrite + cost.output; } -/** - * `reasoningTokens` is intentionally not charged separately: it is already - * counted inside `outputTokens`. - */ +/** Reasoning tokens are already included in outputTokens; do not charge twice. */ export function priceUsage( table: RateTable, record: PricedRecord, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 3e9f3a92596aa..ce8752a867b33 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -33,11 +33,7 @@ export interface CachedFile { readonly mtimeMs: number; readonly provider: UsageProviderKind; readonly records: readonly UsageRecord[]; - /** - * Records from a trailing segment the writer had not newline-terminated at - * parse time. Kept apart from `records` because an incremental parse - * re-reads that segment and would otherwise double count it. - */ + /** Unterminated tail records stay separate because incremental parsing rereads them. */ readonly tailRecords: readonly UsageRecord[]; /** Whole-file count, including diagnostics from the tentative tail. */ readonly malformedRecords: number; @@ -148,11 +144,7 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { }; } -/** - * Entries are replaced, never mutated, when their file changes, so an entry's JSON is memoised by - * identity. The intern tables only grow, so a memoised entry's indexes stay valid; a pruned entry - * can leave an unused string behind until the next process start. - */ +/** Memoizes immutable entries by identity; growing intern tables preserve indexes, retaining unused strings until restart. */ export function makeScanCacheWriter(): ( cache: ScanCache, extra: Readonly>, @@ -209,9 +201,7 @@ export function decodeScanCache( const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. + // Reject entire corrupt entries; partial survivors would appear warm and hide usage until the file changes. const decodeRecords = ( rows: readonly unknown[], provider: UsageProviderKind, @@ -372,10 +362,7 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** - * Callers stitching an incremental parse together pass one `seen` set across the line and tail - * record batches so the whole file stays deduplicated as a unit; the set is mutated in place. - */ +/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ export function dedupeWithinFile( records: readonly UsageRecord[], seen: Set = new Set(), diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts index f965999fb3da8..e7fa7067916c9 100644 --- a/apps/server/src/usage/usageTranscriptReader.test.ts +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -188,8 +188,7 @@ describe("readTranscriptRecords resume", () => { assert.isNotNull(first); assert.strictEqual(first.records.length, 1); - // Codex re-emits an unchanged token_count on stream boundaries; the copy - // lands after the resume point and must still be dropped. + // Codex duplicates token_count at stream boundaries, including after resume points. await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); const second = await readTranscriptRecords(path, TEST_FORMATS.codex, first.position); assert.isNotNull(second); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 1131b95bc7e91..4f131f310f418 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -1,9 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -/** - * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and buffer-level streaming is roughly an order of - * magnitude cheaper than materialising each file. - */ +// Cold windows span ~1.4 GB/1,500 files; node:fs buffer streaming is ~10x cheaper than materializing files. import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeStringDecoder from "node:string_decoder"; @@ -24,12 +20,7 @@ export interface TranscriptFile { readonly mtimeMs: number; } -/** - * The guard hash fingerprints the bytes immediately before `resumeOffset`. A - * resume only proceeds when those bytes still match: transcripts are - * append-only by design, but a rotated or rewritten file silently mis-parsed - * from the middle would corrupt usage totals. - */ +/** Resume only if bytes before resumeOffset match the guard hash; rewritten files would corrupt totals. */ export interface TranscriptParsePosition { readonly resumeOffset: number; readonly guardLength: number; @@ -39,11 +30,7 @@ export interface TranscriptParsePosition { export interface TranscriptParseResult { readonly records: readonly UsageRecord[]; - /** - * Records from a trailing segment the writer has not newline-terminated yet. - * Kept out of `records` because `position` deliberately excludes that - * segment: the next scan re-reads it once the writer finishes the line. - */ + /** Unterminated tail records stay outside records/position so the next scan rereads them. */ readonly tailRecords: readonly UsageRecord[]; /** Includes tentative tail diagnostics; position.state only includes committed lines. */ readonly malformedRecords: number; @@ -78,12 +65,7 @@ function fnv1a(buffer: Buffer): number { return hash >>> 0; } -/** - * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that - * never carry usage, so the basename filter keeps a cold scan off those files. - * Results keep `readdir` order, which the aggregator's first-seen dedupe relies - * on. - */ +/** Skips Grok logs without usage; preserves readdir order for first-seen deduplication. */ export async function listTranscriptFiles( root: string, sinceMs: number, @@ -160,12 +142,7 @@ async function guardMatches( } } -/** - * Several would be one pass over the line each, so they are matched together as - * one alternation over the line's latin1 view instead: latin1 maps every byte - * to one character without validating it, so an ASCII marker matches exactly - * where its bytes do. - */ +// Match markers in one latin1 alternation; one character per byte preserves exact ASCII offsets. export function transcriptLineGate(markers: readonly [string, ...string[]]) { if (markers.length === 1) { const marker = Buffer.from(markers[0]); @@ -177,15 +154,7 @@ export function transcriptLineGate(markers: readonly [string, ...string[]]) { return (line: Buffer) => pattern.test(line.toString("latin1")); } -/** - * Streams one transcript and returns the usage records it contains, or `null` - * when the file could not be read. - * - * The distinction matters to the caller's cache: a genuinely empty transcript - * is a stable fact worth memoising, while a transient read failure memoised - * under the same `(size, mtime)` key would silently drop that file's usage - * until the file next changes. - */ +/** Returns null on read failure; caching it as empty would hide usage until size/mtime changes. */ export async function readTranscriptRecords( filePath: string, format: TranscriptUsageFormat, @@ -252,8 +221,7 @@ export async function readTranscriptRecords( return; } if (!streaming) { - // Usage has no import-history budget: retain all selected usage fields, - // regardless of the size of the surrounding unselected tool content. + // Usage has no import-history budget; retain selected fields regardless of surrounding tool payload size. streaming = createTranscriptJsonReader(() => {}, selectPath, { maxDepth: Infinity }); decoder = new NodeStringDecoder.StringDecoder("utf8"); for (const pending of pendingChunks) streaming.write(decoder.write(pending)); @@ -304,8 +272,7 @@ export async function readTranscriptRecords( scanOffset += chunk.length; } - // The unfinished tail parses against a copy, so the saved state stays at - // `resumeOffset` and the next scan replays the tail from it. + // Parse unfinished tails against copied state so the next scan can replay from resumeOffset. const encodedState = encodeState === undefined ? null : encodeState(state); const tailRecords: UsageRecord[] = []; const tailState = diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index e10518951132a..0e3d2b5063ed2 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -262,7 +262,6 @@ it.effect("checkpoint capture keeps the shared index a split index still uses", yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); assert.strictEqual((yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, "new\n"); - // The real index points at a sharedindex.* file. Reading it fails if the capture expired it. assert.strictEqual((yield* git(["ls-files", "--cached"])).stdout, "file.txt\n"); assert.strictEqual((yield* git(["diff", "--cached", "--name-only"])).stdout, "file.txt\n"); }).pipe(Effect.scoped, Effect.provide(layerGitContract)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 3f839643086b9..aaa4c382ac7c9 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -54,11 +54,7 @@ export interface ExecuteGitInput { readonly forceKillAfterMs?: number; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; - /** - * With `appendTruncationMarker`, keep invoking the line callbacks after the - * buffered copy is full. For long-running commands whose output is only - * consumed through `progress`. - */ + /** With appendTruncationMarker, line callbacks continue beyond the buffer cap for progress-only consumers. */ readonly keepLineCallbacksAfterTruncation?: boolean; readonly progress?: ExecuteGitProgress; } @@ -121,10 +117,7 @@ export interface ExecuteGitProgress { } export interface CreateWorktreeProgress { - /** - * Git refuses an existing path, so a path reported here belongs to this call and is safe to - * remove on cancel. - */ + /** Git refuses existing paths; claimed paths belong to this call and can be removed on cancel. */ readonly onWorktreeClaimed?: (path: string) => Effect.Effect; readonly onCheckoutProgress?: (input: { percent: number; @@ -144,10 +137,7 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; - /** - * Null (or omitted, for callers without settings access) defers to the - * checkout's own t3.json. - */ + /** Null or omitted defers to the checkout's t3.json. */ readonly submodules?: WorktreeSubmodules | null; readonly worktreesDirectory?: string; } @@ -201,15 +191,10 @@ export interface GitRenameBranchResult { branch: string; } -export interface GitFetchPullRequestBranchInput { +export interface GitFetchRefInput { cwd: string; - prNumber: number; - branch: string; -} - -export interface GitFetchPullRequestHeadCommitInput { - cwd: string; - prNumber: number; + /** A ref on the primary remote, such as a host's change request head ref. */ + ref: string; } export interface GitResolveCommitInput { @@ -224,10 +209,7 @@ export interface GitResolveCommitResult { export interface GitRefreshCheckedOutBranchInput { cwd: string; targetCommit: string; - /** - * Commit the checkout is allowed to be hard-reset away from: the upstream commit read before - * the fetch. HEAD sitting there means the checkout holds no work of its own. - */ + /** Pre-fetch upstream commit; resetting away is safe only if HEAD stayed here. */ resetWhenHeadCommit?: string | null | undefined; } @@ -342,6 +324,14 @@ export class GitVcsDriver extends Context.Service< cwd: string, key: string, ) => Effect.Effect; + /** + * The branch, named as on the host, that T3 recorded `branch` was cut from. Null when none + * was recorded. + */ + readonly readBranchBase: ( + cwd: string, + branch: string, + ) => Effect.Effect; readonly resolveBranchName: ( cwd: string, refName: string, @@ -354,11 +344,12 @@ export class GitVcsDriver extends Context.Service< input: VcsCreateWorktreeInput, options?: CreateWorktreeOptions, ) => Effect.Effect; - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, + /** Force-updates local `branch` to the primary remote's `ref`. */ + readonly fetchRefIntoBranch: ( + input: GitFetchRefInput & { readonly branch: string }, ) => Effect.Effect; - readonly fetchPullRequestHeadCommit: ( - input: GitFetchPullRequestHeadCommitInput, + readonly fetchRefCommit: ( + input: GitFetchRefInput, ) => Effect.Effect; readonly resolveCommit: ( input: GitResolveCommitInput, @@ -806,14 +797,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.resolve(physicalCwd, gitCommonDir); }); - // Git renames loose objects and refs into place without fsync by default, so - // an unclean restart can leave 0-byte files under refs/t3/** that break every - // later fetch and push. Checkpoint writes flush before they are published; - // macOS defaults to writeout-only, which does not reach the disk either. + // Git defaults and macOS writeout-only can leave empty refs after crashes; flush before publication. // Set fsync first: Git 2.36 ignores unknown batch without resetting the method. - // Git documents batch/fsync equivalence on macOS HFS+/APFS and Windows NTFS/ReFS; - // keep per-object fsync on other platforms. Batch still fully flushes refs. - // On macOS, VcsProcess retries unsupported full flushes with plain fsync. + // Batch fully flushes refs and equals fsync on macOS HFS+/APFS and Windows NTFS/ReFS. + // Keep per-object fsync elsewhere; VcsProcess retries unsupported macOS full flushes with plain fsync. const durableWrite = [ "-c", "core.fsync=objects,reference", @@ -831,8 +818,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; - // core.splitIndex governs every index Git writes, so an unguarded write to the - // scratch index can expire the sharedindex.* the real .git/index still uses. + // Scratch split-index writes can expire sharedindex files still used by the real index. const indexConfig = [ "-c", "core.fsmonitor=false", @@ -1063,8 +1049,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( Effect.catchTags({ VcsProcessExitError: (error) => Effect.gen(function* () { - // Git cannot stage an embedded repository until it has a commit. Discover these - // only after staging fails so ordinary checkpoints do not need another file scan. + // Git cannot stage uncommitted embedded repos; scan for them only after staging fails. const untracked = yield* execute({ operation, cwd: input.cwd, @@ -1360,8 +1345,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe(Effect.map((result) => result.stdout.split("\0"))); - // Remerge diff lists a merge's paths only where the result differs from Git's - // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes. + // Remerge diffs retain merge conflict fixes while excluding clean upstream changes. const pathLists = yield* Effect.all( [ listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4d7de4b2deb8c..a0ed245333045 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1781,9 +1781,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { baseRef: initialBranch, }); const branch = preview.sources.find((source) => source.kind === "branch-range")!; + const decodeReviewDiffPreviewInput = Schema.decodeEffect(ReviewDiffPreviewInput); + for (const path of ["renamed.md", "[literal].txt", " leading.txt", "mode-only.sh"]) { const stat = branch.files!.find((file) => file.path === path)!; - const request = yield* Schema.decodeEffect(ReviewDiffPreviewInput)({ + const request = yield* decodeReviewDiffPreviewInput({ cwd, baseRef: initialBranch, file: { path, previousPath: stat.previousPath, sourceKind: "branch-range" }, @@ -2377,7 +2379,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { it.effect("Changes compares the default branch with its tracking upstream, not origin", () => Effect.gen(function* () { // `origin` is a fork whose main is behind; the checkout tracks the - // canonical repository through a second remote (#15314). + // canonical repository through a second remote. const canonical = yield* makeTmpDir(); yield* initRepoWithCommit(canonical); yield* git(canonical, ["branch", "-M", "main"]); @@ -3052,7 +3054,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.deepEqual(yield* driver.listLocalBranchNames(cwd), branches); assert.isFalse(yield* fileSystem.exists(pathService.join(worktrees, "wt"))); - // A lone "-" is still the previous checkout. yield* git(cwd, ["checkout", "-q", "keep"]); yield* git(cwd, ["checkout", "-q", initialBranch]); assert.equal((yield* driver.switchRef({ cwd, refName: "-" })).refName, "keep"); @@ -3106,7 +3107,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "HEAD"]), ); assert.equal( - yield* git(cwd, ["config", "branch.feature/parallel.gh-merge-base"]), + yield* git(cwd, ["config", "branch.feature/parallel.t3-merge-base"]), initialBranch, ); for (const [configured, expected] of [ @@ -3157,7 +3158,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), commit); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/revision.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/revision.t3-merge-base"), null, ); }), @@ -3182,7 +3183,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/branch.gh-merge-base"), + yield* driver.readConfigValue(cwd, "branch.feature/branch.t3-merge-base"), baseBranch, ); }), @@ -3268,9 +3269,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - // Git refuses `file:` submodule transports by default (CVE-2022-39253) - // and ignores repo-level config for it, so a local fixture needs the - // env allowance. Real submodules are https/ssh and need none of this. + // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; process.env.GIT_ALLOW_PROTOCOL = "file"; yield* Effect.addFinalizer(() => @@ -3505,7 +3504,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "# test\n", ); assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/locked-config.t3-merge-base"), null, ); assert.equal(yield* fileSystem.exists(pathService.join(cwd, ".git/config.lock")), true); @@ -3777,7 +3776,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(yield* driver.resolvePrimaryRemoteName(cwd), remoteName); yield* driver.fetchRemoteTrackingBranch({ cwd, remoteName, remoteBranch: initialBranch }); yield* driver.fetchRemote({ cwd, remoteName }); - const head = yield* driver.fetchPullRequestHeadCommit({ cwd, prNumber: 7 }); + const head = yield* driver.fetchRefCommit({ cwd, ref: "refs/pull/7/head" }); assert.isFalse(yield* fileSystem.exists((yield* Path.Path).join(cwd, "t3-marker"))); assert.equal(head.commitSha, yield* git(cwd, ["rev-parse", "HEAD"])); @@ -4173,7 +4172,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.strictEqual(status.branchChanges?.deletions, 0); // gh pr create consumes this setting verbatim as the hosted target branch. assert.equal( - yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.gh-merge-base"), + yield* driver.readConfigValue(worktreePath, "branch.feature/from-upstream.t3-merge-base"), "release", ); assert.equal(changes.baseRef, "upstream/release"); @@ -4402,7 +4401,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/x", ); - assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev"); + assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), "dev"); const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ee3beca4d1ad3..68c342d327477 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -31,7 +31,7 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -50,8 +50,16 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import * as ServerConfig from "../config.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +/** T3's record of the branch, named as on the host, that a branch was cut from. */ +const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base`; +/** + * Where T3 kept that record before it had a key of its own, for every host alike: the GitHub + * CLI's key, which branches cut before then still carry instead. + */ +const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const WORKTREE_ADD_TIMEOUT_MS = 300_000; const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); @@ -63,14 +71,10 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -// Every status refresh adds the untracked files to a temporary index, and git add's cost grows -// much faster than the file count (50k files took about 30 seconds). Past this many, the -// totals are reported incomplete instead. +// Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; -// Patches the clients render are parsed against git's default a/ and b/ path -// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would -// otherwise leak into the patch and leave every parsed file unnamed. +// Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const REVIEW_DIFF_ARGS = [ "diff", @@ -322,12 +326,8 @@ export function splitNullSeparatedGitStdoutPaths( return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); } -/** - * Refuses a ref name with a leading "-" before it reaches checkout or `worktree add`. Git refuses - * such branch and tag names, and both commands, or the `git branch` that `worktree add` runs, read - * one as an option. `--end-of-options` cannot guard them: Git before 2.44 reads it like `--`. - * A lone "-" is the previous checkout, which both commands read as a ref. - */ +// Checkout/worktree add parse leading dashes as options; pre-2.44 Git cannot guard with --end-of-options. +// A lone - still means previous checkout. const refuseOptionLikeRefName = (operation: string, cwd: string, refName: string | undefined) => refName !== undefined && refName !== "-" && refName.startsWith("-") ? Effect.fail( @@ -442,10 +442,7 @@ function gitCommandContext( } as const; } -// Git states the actual cause on stderr, but stderr never leaves this module: -// it echoes argv and remote URLs, which can carry credentials. Matching it -// against fixed patterns yields a tag the caller can act on and a message that -// quotes none of the matched text. +// Stderr can echo credentials from argv/URLs; classify fixed patterns without exposing matched text. const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray = [ [/would clobber existing tag/i, "tag_would_be_clobbered"], [/is already (?:used by worktree at|checked out at)/i, "branch_checked_out_in_worktree"], @@ -459,8 +456,7 @@ const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray already exists` also - // covers tag and ref collisions, which are not path collisions. + // Unquoted already-exists errors also describe ref/tag collisions; require quoted paths. [/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"], // Ambiguous revision/path and pathspec failures can name files, so omit them. // Anchor to a diagnostic line to avoid matching quoted paths in other errors. @@ -498,17 +494,10 @@ function gitFailureDetail(reason: GitCommandFailureReason | null): string { // distinguishes them with a separate error line. const GIT_NOT_A_COMMIT_PATTERN = /^error: .+(?:, not a commit|expected commit type)/im; -// Hooks write to the same stream git does, and nothing distinguishes their text -// from git's: a pre-push hook echoing "authentication failed" would otherwise -// be read as a credential failure. `remote:` is deliberately excluded — git -// prefixes every byte the server sends that way, remote hook output included, -// so trusting it would reintroduce the same false positive from the other end -// of the connection. +// Trust only Git's diagnostic prefixes: local hooks can write indistinguishable text. +// Exclude `remote:` too, since it prefixes remote hook output as well as server diagnostics. const GIT_DIAGNOSTIC_LINE_PATTERN = /^(?:fatal|error):|^!\s|^\s+!\s/; -// ssh reports the refusal itself, unprefixed, and git only adds a generic -// "Could not read from remote repository" after it. Dropping ssh's line would -// leave that generic one to be read as an unreachable remote when the real -// cause is credentials, so these specific refusals are classified too. +// SSH credential refusals are unprefixed; Git's later generic error would otherwise misclassify them as unreachable. const SSH_TRANSPORT_REFUSAL_PATTERN = /Permission denied \((?:publickey|password|keyboard-interactive)|Permission denied, please try again|Host key verification failed/i; @@ -572,9 +561,7 @@ function isUnbornHeadStderr(stderr: string): boolean { ); } -// Matches `git worktree remove` on a path git no longer tracks: "is not a -// working tree" when the registration is gone, "cannot remove working tree" -// when older gits fail validation on a registered-but-deleted directory. +// Git reports missing worktree registrations and registered-but-deleted paths differently. function isMissingWorktreeStderr(stderr: string): boolean { const normalized = stderr.toLowerCase(); return ( @@ -583,8 +570,7 @@ function isMissingWorktreeStderr(stderr: string): boolean { ); } -// Fetch stderr can contain remote credentials. Only fixed diagnoses may enter -// persisted errors; unrecognized output keeps the generic failure message. +// Fetch stderr can carry credentials; persist only fixed diagnoses or generic failures. function fetchFailureDetail(stderr: string): string | undefined { const lines = stderr.split(/\r?\n/).map((line) => line.trim()); if ( @@ -620,8 +606,7 @@ function fetchFailureDetail(stderr: string): string | undefined { return undefined; } -// Matches a fetch that lost a ref update to another Git process. Git 2.53+ -// reports it as "fetching ref failed: ..."; older Git says "cannot lock ref". +// Git 2.53+ reports ref races as fetching-ref failures; older versions say cannot lock ref. function isRefLockFailure(stderr: string): boolean { return stderr .split(/\r?\n/) @@ -859,12 +844,10 @@ const collectOutput = Effect.fnUntraced(function* ( let text = ""; let lineBuffer = ""; let truncated = false; - // A separator-free stream past the cap must not grow the line buffer - // without bound; a line longer than this is not one the callbacks want. + // Bound newline-free output; oversized lines are not useful to callbacks. const maxPendingLineBytes = 64 * 1024; - // Git redraws progress with a bare `\r` between updates and only ends the - // line once the step is done, so `\r` has to count as a line break here. + // Git redraws progress with bare CR; treat it as a line break. const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { let separator = OUTPUT_LINE_SEPARATOR.exec(lineBuffer); while (separator) { @@ -942,12 +925,7 @@ const collectOutput = Effect.fnUntraced(function* ( }; }); -/** - * Git on Windows cannot create or delete a path longer than MAX_PATH (260) - * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not - * cover it, and git leaves it off by default, so deep worktrees fail to check - * out and fail midway through removal. - */ +/** Windows Git needs core.longpaths beyond MAX_PATH (260); OS LongPathsEnabled alone is insufficient. */ export const windowsLongPathConfigEnv = ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, @@ -990,8 +968,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const baseEnv = { ...process.env, - // Status polling runs beside the user's own git commands; without this, - // `git status` takes index.lock to save its refreshed index. + // Concurrent status polls must not take index.lock to save refreshed indexes. GIT_OPTIONAL_LOCKS: "0", ...input.env, ...trace2Monitor.env, @@ -999,8 +976,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const env = { ...baseEnv, ...explicitBareRepositoryEnv(baseEnv, hostPlatform) }; const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); - // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit - // messages at the first newline; leave that case to Node's lookup. + // cmd wrappers truncate multiline commits; leave git.cmd handling to Node lookup. const executable = resolved.shell ? "git" : resolved.command; const child = yield* commandSpawner .spawn( @@ -1204,10 +1180,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((result) => result.stdout), ); - /** - * Runs `git worktree list --porcelain -z`. Git before 2.36 rejects `-z`, so a failed run - * retries the line form and returns it NUL-delimited, the same shape as `-z` output. - */ + // Pre-2.36 Git rejects worktree list -z; normalize its line fallback to NUL records. const executeWorktreeList = Effect.fn("executeWorktreeList")(function* ( operation: string, cwd: string, @@ -1607,10 +1580,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // A failed background fetch is intentionally cached and exponentially - // backed off. Status reads swallow this failure and use the last fetched - // refs, so repeated thread mounts cannot turn a slow or unavailable remote - // into a repository-wide Git subprocess storm. + // Cache/back off failed fetches while status uses old refs; repeated mounts must not cause subprocess storms. timeToLive: (exit, cacheKey) => Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL @@ -1739,7 +1709,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "ensureRemote", )(function* (input) { const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeGitRemoteUrl(input.url); + const normalizedTargetUrl = sourceControlDefinitions.repositoryKeyFromRemote(input.url); const remoteFetchUrls = yield* runGitStdout( "GitVcsDriver.ensureRemote.listRemoteUrls", input.cwd, @@ -1747,7 +1717,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) { + if (sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) === normalizedTargetUrl) { return remoteName; } } @@ -1829,8 +1799,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - const configuredBaseBranch = - (yield* readConfigValue(cwd, `branch.${refName}.gh-merge-base`)) ?? ""; + const configuredBaseBranch = (yield* readBranchBase(cwd, refName)) ?? ""; const configuredBase = yield* resolveComparisonBaseRef(cwd, configuredBaseBranch); if ( configuredBase !== null && @@ -2132,8 +2101,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusCacheKey = repositoryPaths?.gitCommonDir; const [numstatEntries, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ - // Plumbing, because porcelain `git diff` rewrites the index even with - // GIT_OPTIONAL_LOCKS=0. -M keeps porcelain's rename detection. + // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, @@ -2633,11 +2601,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - // A branch tracking a differently named ref was cut from it, the way `git - // checkout -b feature origin/dev` and our own worktree flow leave it. - // That upstream is the branch's base, not its publish target, and pushing - // HEAD onto it would write feature commits to a shared branch (bare `git - // push` refuses this under push.default=simple). + // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. const isAliasOfUpstreamHead = branch === currentUpstream.branchName || (branch.endsWith(`/${currentUpstream.branchName}`) && @@ -2649,16 +2613,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteName = publishRemoteName ?? currentUpstream.remoteName; const publishBranch = yield* resolvePublishBranchName(cwd, branch); // Publishing retargets the upstream, so preserve its comparison base first. - const configuredMergeBase = yield* runGitStdout( - "GitVcsDriver.pushCurrentBranch.readMergeBase", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (configuredMergeBase.length === 0) { + if ((yield* readBranchBase(cwd, branch)) === null) { yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [ "config", - `branch.${branch}.gh-merge-base`, + branchBaseConfigKey(branch), currentUpstream.branchName, ]); if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { @@ -2903,8 +2861,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (indexExists) { const { mtime } = yield* fileSystem.stat(indexPath); yield* fileSystem.copyFile(indexPath, tempIndexPath); - // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. - // Flooring preserves the source second without making preceding-second files racy. + // stat truncates bigint times to ms; floor seconds to avoid making preceding-second files racy. const indexTime = Option.isSome(mtime) ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) : 0; @@ -3380,6 +3337,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); + const readBranchBase: GitVcsDriver.GitVcsDriver["Service"]["readBranchBase"] = Effect.fn( + "GitVcsDriver.readBranchBase", + )(function* (cwd, branch) { + const base = yield* readConfigValue(cwd, branchBaseConfigKey(branch)); + return base ?? (yield* readConfigValue(cwd, legacyBranchBaseConfigKey(branch))); + }); + const resolveBranchBase = Effect.fn("GitVcsDriver.resolveBranchBase")(function* ( cwd: string, refName: string, @@ -3620,10 +3584,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); const snapshot = refresh || currentEpoch === undefined - ? // The refresh cache owns the complete snapshot read, rather than only the - // epoch bump. Slow repositories therefore remain singleflight for the - // entire Git scan even when more refresh requests arrive after the - // coalescing TTL would otherwise have elapsed. + ? // Cache the entire snapshot read so slow scans stay single-flight beyond the coalescing TTL. yield* Cache.get( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ gitCommonDir, generation }), @@ -3731,7 +3692,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refuseOptionLikeRefName("GitVcsDriver.createWorktree", input.cwd, input.newRefName); const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); - // resolve: a cwd like `/sub/..` must not name the folder `..`. + // Resolve sub/.. before taking the basename. const repoName = path.basename(path.resolve(input.cwd)); let worktreePath = input.path; if (worktreePath == null) { @@ -3785,9 +3746,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onWorktreeClaimed(worktreePath); } - // `git worktree add` leaves submodules empty, so a repo that keeps agent - // skills, tooling or source in one gets a worktree that is quietly missing - // them. + // worktree add leaves submodules empty, potentially omitting project skills, tools or source. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); @@ -3853,17 +3812,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - if (input.newRefName && input.baseRefName) { + const newRefName = input.newRefName; + if (newRefName && input.baseRefName) { const base = yield* resolveBranchBase(input.cwd, input.baseRefName); if (base) { yield* Effect.gen(function* () { yield* runGit("GitVcsDriver.createWorktree.configureBaseRef", input.cwd, [ "config", - `branch.${input.newRefName}.gh-merge-base`, + branchBaseConfigKey(newRefName), base.branchName, ]); if (base.baseRef.startsWith("refs/remotes/")) { - // gh consumes its key as a provider branch; T3 also needs the selected remote. + // The base key names a branch on the host; this one keeps the remote it came from. yield* runGit("GitVcsDriver.createWorktree.configureComparisonRef", input.cwd, [ "config", `branch.${input.newRefName}.t3-merge-base-ref`, @@ -3889,25 +3849,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); - const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = - Effect.fn("fetchPullRequestBranch")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitVcsDriver.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - "--", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorDetail: "git fetch pull request branch failed", - }, - ); - }); + const fetchRefIntoBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRefIntoBranch"] = Effect.fn( + "GitVcsDriver.fetchRefIntoBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchRefIntoBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + "--", + remoteName, + `+${input.ref}:refs/heads/${input.branch}`, + ], + { + fallbackErrorDetail: "git fetch ref into branch failed", + }, + ); + }); const resolveCommit: GitVcsDriver.GitVcsDriver["Service"]["resolveCommit"] = Effect.fn( "resolveCommit", @@ -3921,22 +3882,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { commitSha }; }); - const fetchPullRequestHeadCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestHeadCommit"] = - Effect.fn("fetchPullRequestHeadCommit")(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - // No refspec destination: the pull head lands in FETCH_HEAD (per worktree) instead of a - // branch, which is the only way to read it while that branch is checked out somewhere. - yield* executeGit( - "GitVcsDriver.fetchPullRequestHeadCommit", - input.cwd, - ["fetch", "--quiet", "--no-tags", "--", remoteName, `refs/pull/${input.prNumber}/head`], - { - fallbackErrorDetail: "git fetch pull request head failed", - }, - ); + const fetchRefCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchRefCommit"] = Effect.fn( + "GitVcsDriver.fetchRefCommit", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + // No refspec destination: the ref lands in FETCH_HEAD (per worktree) instead of a branch, + // which is the only way to read it while that branch is checked out somewhere. + yield* executeGit( + "GitVcsDriver.fetchRefCommit", + input.cwd, + ["fetch", "--quiet", "--no-tags", "--", remoteName, input.ref], + { + fallbackErrorDetail: "git fetch ref failed", + }, + ); - return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); - }); + return yield* resolveCommit({ cwd: input.cwd, revision: "FETCH_HEAD" }); + }); const refreshCheckedOutBranch: GitVcsDriver.GitVcsDriver["Service"]["refreshCheckedOutBranch"] = Effect.fn("refreshCheckedOutBranch")(function* (input) { @@ -3960,17 +3922,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["merge-base", "--is-ancestor", headCommit, input.targetCommit], { allowNonZeroExit: true }, ).pipe(Effect.map((result) => result.exitCode === 0)); - // A rewritten head (rebase, squash, amend) does not descend from the checkout, so it can - // only be taken by resetting. That is lossless exactly when the tree is clean and HEAD - // never left the commit the upstream held before the fetch. + // Rewritten heads require reset; allow only clean checkouts still at the pre-fetch upstream commit. if (!isAncestor && headCommit !== input.resetWhenHeadCommit) { return { headCommit, moved: false, onTarget: false }; } if (!isAncestor) { - // The commit being reset away is about to be reachable from nothing. It is only ever a - // commit the remote already held, but "the remote held it" stops being a way back once - // the head it belonged to has been rewritten, so a ref keeps it findable. + // Retain the reset-away commit; rewritten remote heads no longer provide a path back. yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.keepPrevious", input.cwd, @@ -3982,10 +3940,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit( "GitVcsDriver.refreshCheckedOutBranch.move", input.cwd, - // `--merge` rather than `--hard`: the cleanliness check above is a snapshot, and another - // thread may edit a tracked file between it and this move. Git itself refuses a `--merge` - // reset that would overwrite such an edit — the same guarantee `--ff-only` gives the - // other branch — so a race loses nothing; the refresh fails and is reported instead. + // reset --merge refuses tracked edits racing our cleanliness check, preserving work like --ff-only does. isAncestor ? ["merge", "--ff-only", input.targetCommit] : ["reset", "--merge", input.targetCommit], @@ -4018,8 +3973,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); return run.pipe( - Effect.flatMap((result) => - result.exitCode !== 0 && isRefLockFailure(result.stderr) ? run : Effect.succeed(result), + Effect.filterOrElse( + (result) => !(result.exitCode !== 0 && isRefLockFailure(result.stderr)), + (_result) => run, ), ); }; @@ -4233,12 +4189,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.provideService(Path.Path, path), ); if (result.exitCode === 0) { - // Git for Windows never descends into NTFS junctions, such as pnpm's - // node_modules links. It reports success but leaves them and their parent - // directories behind, so a resumed thread would find a stub instead of - // recreating its checkout. Git has already unregistered the worktree, so - // anything left here is logged rather than returned: a retry could never - // succeed. + // Git for Windows reports success without removing NTFS junctions or their parent directories. + // Log leftover cleanup failures: Git already unregistered the worktree, so removal cannot be retried. if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))) { const removed = yield* removeLeftoverLinks(target).pipe( Effect.catch((error) => @@ -4257,9 +4209,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } return; } - // Threads can share a worktree path, and worktrees get removed or pruned - // outside the app, so a worktree that is already gone is a no-op rather - // than an error. + // Threads share paths and outside Git can remove worktrees; already-gone removals are no-ops. const alreadyGone = isMissingWorktreeStderr(result.stderr) && !(yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false))); @@ -4463,8 +4413,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const listWorktreePaths: GitVcsDriver.GitVcsDriver["Service"]["listWorktreePaths"] = (cwd) => executeWorktreeList("GitVcsDriver.listWorktreePaths", cwd, []).pipe( - // One record per worktree, each ended by an empty field. A `prunable` - // record's directory is gone, and another checkout may now sit there. + // A `prunable` record's directory is gone; another checkout may now sit there. Effect.map(({ stdout }) => stdout.split("\0\0").flatMap((record) => { const fields = record.split("\0"); @@ -4502,6 +4451,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // Forwarders retain the underlying Git operation spans. return GitVcsDriver.GitVcsDriver.of({ execute, status, @@ -4509,30 +4459,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetailsLocal, statusDetailsRemote, prepareCommitContext, - commit: (cwd, subject, body, options) => - withListRefsInvalidation(cwd, commit(cwd, subject, body, options)), - pushCurrentBranch: (cwd, fallbackBranch, options) => - withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)), - pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)), + commit: Effect.fnUntraced(function* (cwd, subject, body, options) { + return yield* withListRefsInvalidation(cwd, commit(cwd, subject, body, options)); + }), + pushCurrentBranch: Effect.fnUntraced(function* (cwd, fallbackBranch, options) { + return yield* withListRefsInvalidation(cwd, pushCurrentBranch(cwd, fallbackBranch, options)); + }), + pullCurrentBranch: Effect.fnUntraced(function* (cwd) { + return yield* withListRefsInvalidation(cwd, pullCurrentBranch(cwd)); + }), readRangeContext, getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, + readBranchBase, resolveBranchName, listRefs, - createWorktree: (input, options) => - withListRefsInvalidation(input.cwd, createWorktree(input, options)), - fetchPullRequestBranch: (input) => - withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), - fetchPullRequestHeadCommit, + createWorktree: Effect.fnUntraced(function* (input, options) { + return yield* withListRefsInvalidation(input.cwd, createWorktree(input, options)); + }), + fetchRefIntoBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRefIntoBranch(input)); + }), + fetchRefCommit, resolveCommit, - refreshCheckedOutBranch: (input) => - withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), - ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), + refreshCheckedOutBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)); + }), + ensureRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, ensureRemote(input)); + }), resolvePrimaryRemoteName, resolveDefaultBranchName, - fetchRemote: (input) => - withListRefsInvalidation( + fetchRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation( input.cwd, fetchRemote(input).pipe( Effect.flatMap((fetched) => @@ -4541,23 +4501,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : fetchRemote({ cwd: input.cwd, remoteName: input.remoteName }).pipe(Effect.asVoid), ), ), - ), + ); + }), remoteExists, remoteBranchExists, resolveRemoteTrackingCommit, resolveWorktreeBaseRemote, - fetchWorktreeBaseCommit: (input) => - withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)), - fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), - fetchRemoteTrackingBranch: (input) => - withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)), - setBranchUpstream: (input) => withListRefsInvalidation(input.cwd, setBranchUpstream(input)), - removeWorktree: (input) => withListRefsInvalidation(input.cwd, removeWorktree(input)), - pruneWorktrees: (input) => withListRefsInvalidation(input.cwd, pruneWorktrees(input)), - deleteLocalBranch: (input) => withListRefsInvalidation(input.cwd, deleteLocalBranch(input)), - renameBranch: (input) => withListRefsInvalidation(input.cwd, renameBranch(input)), - createRef: (input) => withListRefsInvalidation(input.cwd, createRef(input)), - switchRef: (input) => withListRefsInvalidation(input.cwd, switchRef(input)), + fetchWorktreeBaseCommit: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchWorktreeBaseCommit(input)); + }), + fetchRemoteBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)); + }), + fetchRemoteTrackingBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, fetchRemoteTrackingBranch(input)); + }), + setBranchUpstream: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, setBranchUpstream(input)); + }), + removeWorktree: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, removeWorktree(input)); + }), + pruneWorktrees: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, pruneWorktrees(input)); + }), + deleteLocalBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, deleteLocalBranch(input)); + }), + renameBranch: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, renameBranch(input)); + }), + createRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, createRef(input)); + }), + switchRef: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, switchRef(input)); + }), initRepo: initRepoWithListRefsInvalidation, listLocalBranchNames, listWorktreePaths, diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index b0273c96da07e..83d7abe6a10e1 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -14,9 +14,7 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; -// Status polls detect every open cwd each tick, and detection costs three git -// processes. `detect` re-checks each hit against the disk, so this TTL only -// bounds rare changes those stats cannot see. +// Detection costs three git processes per cwd; cached hits still stat disk, so TTL bounds only undetectable changes. const DETECTION_CACHE_TTL = Duration.minutes(5); export interface VcsDriverResolveInput { @@ -118,8 +116,7 @@ export const make = Effect.gen(function* () { return yield* detectWithDriver("git", git, input.cwd); }); - // Falls back to the cwd alone when the root is not above it, such as a cwd - // reached through a symlink. + // Symlinks can put cwd outside root; then inspect cwd alone. const foldersBelowRoot = (cwd: string, rootPath: string) => { const relative = path.relative(path.resolve(rootPath), path.resolve(cwd)); if (relative.startsWith("..") || path.isAbsolute(relative)) return [cwd]; diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 03d274fdd823a..212415171bdc6 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -258,7 +258,7 @@ describe("VcsProcess.run", () => { }), ); - it.effect("bounds a synthetic burst of GitHub API processes", () => + it.effect("bounds a burst of processes that share a concurrency key", () => Effect.gen(function* () { const gate = yield* Deferred.make(); const starts = yield* Queue.unbounded(); @@ -294,10 +294,11 @@ describe("VcsProcess.run", () => { const burst = yield* Effect.all( Array.from({ length: 32 }, (_, index) => service.run({ - operation: `synthetic.github.${index}`, - command: "gh", + operation: `synthetic.cli.${index}`, + command: "host-cli", args: ["api", "user"], cwd: "/workspace", + concurrency: { key: "host-cli", limit: 4 }, }), ), { concurrency: "unbounded" }, @@ -391,6 +392,30 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("reads a failure with the command's own classifier and names it in its terms", () => + Effect.gen(function* () { + const fail = (input: Partial) => + run({ + operation: "test.classify", + command: "node", + args: ["-e", "process.stderr.write('HTTP 404: 404 Not Found'); process.exit(1)"], + cwd: process.cwd(), + ...input, + }).pipe(Effect.flip); + + expect(yield* fail({})).toMatchObject({ + failureKind: "command-failed", + detail: "Process exited with a non-zero status.", + }); + expect( + yield* fail({ + classifyFailure: (stderr) => (stderr.includes("404") ? "not-found" : null), + notFoundDetail: "Merge request not found.", + }), + ).toMatchObject({ failureKind: "not-found", detail: "Merge request not found." }); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 2a62c6fb1ac4c..e2055e3400531 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -20,6 +20,7 @@ import { VcsProcessTimeoutError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { SourceControlProcessInput } from "@t3tools/source-control-core/server/SourceControlHost"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,9 @@ export interface VcsProcessInput { readonly maxOutputBytes?: number; readonly outputMode?: ProcessRunner.ProcessRunInput["outputMode"]; readonly appendTruncationMarker?: boolean; + readonly classifyFailure?: SourceControlProcessInput["classifyFailure"]; + readonly notFoundDetail?: string; + readonly concurrency?: SourceControlProcessInput["concurrency"]; } export interface VcsProcessOutput { @@ -61,7 +65,6 @@ const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; const VCS_PROCESS_CONCURRENCY = 8; -const GITHUB_PROCESS_CONCURRENCY = 4; /** Shown to users when a Git command fails because another process holds the index lock. */ export const GIT_INDEX_LOCKED_DETAIL = @@ -91,14 +94,10 @@ const gitMetricOperations = new Set([ ]); /** - * Appends `key=value` to the `GIT_CONFIG_*` entries git reads as command-line - * config, so argv stays the same on every platform and nothing is written to - * the user's config. The entry goes after any inherited ones. Windows env names - * are case-insensitive (elsewhere git reads only `GIT_CONFIG_COUNT`), so there the - * count is found regardless of casing, read from - * the last spelling (a per-command value spread over process.env), and updated - * under every spelling so the child sees it whichever one Node keeps. A count - * git would reject is left alone so git still reports it. + * Append after inherited entries without changing argv or the user's config. + * Windows env keys ignore case: read the last spelling of the count so command overrides win, + * then update every spelling because Node may keep any one. + * Leave invalid counts untouched so Git reports them. */ export const gitConfigEnv = ( env: NodeJS.ProcessEnv, @@ -114,8 +113,7 @@ export const gitConfigEnv = ( if ( inherited !== undefined && inherited !== "" && - // Git parses the count with strtoul: leading whitespace, an optional sign - // (only -0 is non-negative), digits, and nothing after them. + // Match Git's strtoul count syntax, including whitespace, signs and non-negative -0. /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited ) { return {}; @@ -129,25 +127,23 @@ export const gitConfigEnv = ( }; /** - * Git otherwise opens any folder holding `HEAD`, `objects/` and `refs/` as a - * bare repository and applies its `config`, including commands such as - * `core.fsmonitor`. Files saved in a project or checked out from a clone can - * form one, so git started by T3 (or by gh and friends) only opens a bare - * repository named with `--git-dir` or `GIT_DIR`. Git before 2.38 ignores it. + * A project can contain a fake bare repository whose config runs commands such as `core.fsmonitor`. + * Require `--git-dir` or `GIT_DIR` to open bare repositories; Git before 2.38 ignores this setting. */ export const explicitBareRepositoryEnv = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform) => gitConfigEnv(env, "safe.bareRepository", "explicit", platform); -const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => { +const classifyNonZeroExit = ( + stderr: string, + classifyFailure: VcsProcessInput["classifyFailure"], +): VcsProcessExitFailureKind => { const normalized = stderr.toLowerCase(); + const commandKind = classifyFailure?.(stderr) ?? null; if ( + commandKind === "authentication" || normalized.includes("authentication failed") || normalized.includes("not logged in") || - normalized.includes("gh auth login") || - normalized.includes("glab auth login") || - normalized.includes("az devops login") || - normalized.includes("please run az login") || normalized.includes("no oauth token") || normalized.includes("unauthorized") ) { @@ -155,6 +151,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai } if ( + commandKind === "rate-limited" || normalized.includes("api rate limit") || normalized.includes("rate limit exceeded") || normalized.includes("secondary rate limit") || @@ -164,24 +161,7 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "rate-limited"; } - if ( - (command === "gh" && - (normalized.includes("could not resolve to a pullrequest") || - normalized.includes("repository.pullrequest") || - normalized.includes("no pull requests found for branch") || - normalized.includes("pull request not found"))) || - (command === "glab" && - (normalized.includes("merge request not found") || - normalized.includes("not found") || - normalized.includes("404"))) || - (command === "az" && - normalized.includes("pull request") && - (normalized.includes("not found") || normalized.includes("does not exist"))) - ) { - return "not-found"; - } - - return "command-failed"; + return commandKind ?? "command-failed"; }; // Classify before discarding stderr; keep paths and process output out of errors. @@ -204,7 +184,15 @@ export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const hostPlatform = yield* HostProcess.Platform; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); - const githubProcesses = yield* Semaphore.make(GITHUB_PROCESS_CONCURRENCY); + const keyedProcesses = new Map(); + const keyedPermit = (concurrency: NonNullable) => { + let semaphore = keyedProcesses.get(concurrency.key); + if (semaphore === undefined) { + semaphore = Semaphore.makeUnsafe(concurrency.limit); + keyedProcesses.set(concurrency.key, semaphore); + } + return semaphore.withPermits(1); + }; const spawn = Effect.fn("VcsProcess.spawn")(function* ( input: VcsProcessInput, @@ -315,7 +303,7 @@ export const make = Effect.gen(function* () { } if (!input.allowNonZeroExit && result.code !== 0) { - const failureKind = classifyNonZeroExit(input.command, result.stderr); + const failureKind = classifyNonZeroExit(result.stderr, input.classifyFailure); const error = VcsProcessExitError.fromProcessExit( baseError, { @@ -324,9 +312,13 @@ export const make = Effect.gen(function* () { stderrTruncated: result.stderrTruncated, }, failureKind, - input.command === "git" && - failureKind === "command-failed" && - isTransientGitExit(result.stderr), + { + retryable: + input.command === "git" && + failureKind === "command-failed" && + isTransientGitExit(result.stderr), + ...(input.notFoundDetail === undefined ? {} : { notFoundDetail: input.notFoundDetail }), + }, ); if ( input.command === "git" && @@ -374,7 +366,9 @@ export const make = Effect.gen(function* () { }), ); } - return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded; + return yield* input.concurrency === undefined + ? bounded + : keyedPermit(input.concurrency)(bounded); }); return VcsProcess.of({ run }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 74d3801c06861..dfd76b1f2b48b 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -64,7 +64,7 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => }), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index e58099b615dbd..96674e940f03b 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -35,7 +35,7 @@ function resolveRequestedKind( return Effect.succeed(kind); } -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 334c0d4747bce..afb56e53e3a0d 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -156,6 +156,119 @@ function layerBackgroundPolicy(shouldRunScopeWork: (scope: BackgroundScope) => b } describe("VcsStatusBroadcaster", () => { + it.effect("publishes a terminal branch switch without reading remote status", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const events = yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.tap((event) => + event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + + state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + assert.equal(refreshed.refName, "main"); + assert.isNull(refreshed.pr); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + assert.deepStrictEqual((yield* Fiber.join(events))[1], { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: null, + }); + assert.isNull((yield* broadcaster.getStatus({ cwd: "/repo" })).pr); + const snapshot = yield* broadcaster + .streamStatus({ cwd: "/repo", includeRemote: false }) + .pipe(Stream.take(1), Stream.runCollect); + assert.deepStrictEqual(snapshot[0], { + _tag: "snapshot", + local: state.currentLocalStatus, + remote: null, + }); + }).pipe(Effect.provide(layerTestFor(state))); + }); + + it.effect( + "preserves cached remote status when a local-only refresh stays on the same branch", + () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 2, behindCount: 3 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + const refreshed = yield* broadcaster.refreshStatus("/repo", { localOnly: true }); + assert.deepStrictEqual(refreshed, initial); + assert.deepStrictEqual(yield* broadcaster.getStatus({ cwd: "/repo" }), refreshed); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(layerTestFor(state))); + }, + ); + + it.effect("clears the previous branch's remote status after an in-flight PR refresh", () => { + const remoteReadStarted = Deferred.makeUnsafe(); + const releaseRemoteRead = Deferred.makeUnsafe(); + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + beforeRemoteStatus: Effect.void, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + state.beforeRemoteStatus = Deferred.succeed(remoteReadStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRemoteRead)), + ); + const remoteRefresh = yield* broadcaster + .refreshPullRequestStatus("/repo") + .pipe(Effect.forkScoped); + yield* Deferred.await(remoteReadStarted); + state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + const localRefresh = yield* broadcaster + .refreshStatus("/repo", { localOnly: true }) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseRemoteRead, undefined); + yield* Fiber.join(remoteRefresh); + yield* Fiber.join(localRefresh); + + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(status.refName, "main"); + assert.isNull(status.pr); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + assert.equal(state.remoteStatusCalls, 2); + }).pipe(Effect.provide(layerTestFor(state))); + }); + it.effect.skipIf(!symlinksSupported)( "automatically pulls an enabled clean default branch when status detects it is behind", () => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 41859b9dd9072..3cbcb3546e9d9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -207,7 +207,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly refreshStatus: ( + cwd: string, + options?: { readonly localOnly?: boolean }, + ) => Effect.Effect; readonly refreshPullRequestStatus: ( cwd: string, ) => Effect.Effect; @@ -227,7 +230,7 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const automaticFetchInterval = yield* VcsAutomaticFetchInterval; const autoPullPolicy = yield* VcsAutoPullPolicy; @@ -243,7 +246,7 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); // One permit per cwd for remote reads that write the cache. Without it a - // periodic poll that started before `gh pr create` can finish after the + // periodic poll that started before an agent creates a PR can finish after the // turn-end refresh and overwrite the fresh PR with its stale `pr: null`. const remoteWriteLocks = yield* KeyedLock.make(); const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => @@ -460,8 +463,7 @@ export const make = Effect.gen(function* () { errorTag: error._tag, }); if (!pullAttempted) return null; - // A pull or its follow-up reads can fail after local state has changed. - // Let a failed reconciliation reach the caller instead of publishing old status. + // Pull can change local state before failing; propagate reconciliation failures instead of publishing stale status. yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); yield* updateCachedStatus(cwd, local, remote, { publish: true }); @@ -507,10 +509,25 @@ export const make = Effect.gen(function* () { const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { + )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - // invalidateStatus (not the two partial invalidations) so an explicit - // refresh also bypasses GitManager's slow PR-lookup cache. + if (options?.localOnly) { + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + const previous = yield* getCachedStatus(cwd); + if (previous?.local?.value.refName !== local.refName) { + yield* workflow.invalidateRemoteStatus(cwd); + return yield* updateCachedStatus(cwd, local, null, { publish: true }); + } + yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return mergeGitStatusParts(local, previous?.remote?.value ?? null); + }), + ); + } + // Full invalidation also bypasses GitManager's slow PR cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -544,8 +561,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRefresh) return null; - // Resolve the checked-out branch again. A cached PR can belong to - // the previous branch after an agent checks out another branch. + // Re-resolve the branch; cached PRs may belong to a checkout's previous branch. const remote = yield* workflow.remoteStatus( { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index c8feb71c3d845..f1140cdcc3a48 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -10,17 +10,9 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; /** - * Removes the linked worktree at `worktreePath` by running `git worktree - * remove` through `run`. - * - * Git for Windows before 2.54 follows NTFS junctions while it deletes a - * worktree, and deletes what they point to, such as a shared node_modules. - * Later versions skip junctions but leave them, and the folders holding them, - * behind. On Windows, Git therefore never deletes the files: this makes the - * checks `git worktree remove` makes before deleting (builtin/worktree.c), - * deletes the folder with Node's `rm`, which unlinks junctions without - * following them, and then has Git drop the now-missing worktree's metadata. - * Git elsewhere only unlinks links, so other hosts run Git as before. + * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. + * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which + * unlinks junctions without following them. Git then removes the worktree metadata. */ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorktree")(function* < A, diff --git a/apps/server/src/workspace/KnownWorkspaces.ts b/apps/server/src/workspace/KnownWorkspaces.ts index 20df30331029b..f442e4cedbb61 100644 --- a/apps/server/src/workspace/KnownWorkspaces.ts +++ b/apps/server/src/workspace/KnownWorkspaces.ts @@ -1,12 +1,4 @@ -/** - * KnownWorkspaces - the folders a client-named cwd may act in: a T3-managed - * worktree folder, a registered project, or a thread's worktree. Clients send - * the thread's worktree, else the project root. A client also sets a thread's - * worktree path, so one counts only under a managed folder or when git lists - * it as a worktree of the thread's project. - * - * @module KnownWorkspaces - */ +// Client-supplied worktrees count only under managed folders or when Git lists them for the project. import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Context from "effect/Context"; @@ -35,7 +27,7 @@ export class UnknownWorkspaceError extends Schema.TaggedError { const relative = path.relative(root, candidate); return ( @@ -47,14 +39,14 @@ export const isWithinRoot = (path: Path.Path, candidate: string, root: string) = export class KnownWorkspaces extends Context.Service< KnownWorkspaces, { - /** Whether a symlink-resolved path is inside a known workspace. */ + /** Takes a symlink-resolved path. */ readonly contains: (realPath: string) => Effect.Effect; /** Fails unless `cwd` exists and resolves inside a known workspace. */ readonly assertContains: (cwd: string) => Effect.Effect; } >()("t3/workspace/KnownWorkspaces") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -64,8 +56,7 @@ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const git = yield* GitVcsDriver.GitVcsDriver; - // A root that does not exist yet keeps its lexical path. One that cannot be - // resolved for another reason, or resolves to a filesystem root, grants nothing. + // Missing roots keep lexical paths; other resolution failures and filesystem roots grant nothing. const resolveRoot = (value: string) => { const resolvedPath = path.resolve(value); return fileSystem.realPath(resolvedPath).pipe( @@ -130,8 +121,7 @@ export const make = Effect.gen(function* () { return real !== null && listed.includes(real); }); for (const { worktreePath, projectId } of threads) { - // A relative path would resolve against the server's own cwd. One under a - // managed folder is still resolved: it can be a symlink to a checkout elsewhere. + // Relative paths use server cwd; managed paths still need resolution to reject symlinks elsewhere. if (!path.isAbsolute(worktreePath)) continue; if (!within(yield* resolveRoot(worktreePath))) continue; const lexical = path.resolve(worktreePath); diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 150ac2c69131a..730d02c577f43 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -174,7 +174,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; for (const { directoryPath, ignored, except } of [ { directoryPath: "node_modules", ignored: true, except: [] }, - // A force-tracked file and a folder holding tracked files stay unignored. { directoryPath: "dist", ignored: true, except: ["dist/kept.js", "dist/vendored"] }, // git reads `[id]/page.tsx` as a pathspec, which matches the tracked `i/page.tsx`. { directoryPath: "[id]", ignored: true, except: ["[id]/page.tsx"] }, @@ -183,8 +182,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ? [ // git refuses to classify paths beyond a symlink, so nothing there is labelled. { directoryPath: "node_modules/link", ignored: false, except: [] }, - // A leading `:` is pathspec magic, so git reads `:foo/pkg-0.js` as `foo/pkg-0.js`, - // which `!foo/` keeps unignored. + // Leading : is pathspec magic; Git sees foo/pkg-0.js, unignored by !foo/. { directoryPath: ":foo", ignored: false, except: [] }, ] : []), @@ -203,8 +201,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); it.effect("lists a host folder and its parent, each from its own root", () => - // Folder links outside the workspace browse by passing the host folder as - // `cwd`; walking up passes the parent. Each stays bounded by its own root. Effect.gen(function* () { const parent = yield* makeTempDir(); const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index a0d35202c2114..5f1c5f2660a68 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -145,7 +145,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu ); }); -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -334,8 +334,7 @@ export const make = Effect.gen(function* () { }, catch: toError, }); - // Use stdin so large directories cannot exceed the command-line argument limit. - // Ignore classification is optional in non-git workspaces or when git is unavailable. + // stdin avoids argument limits; ignore classification is optional without git/repositories. const checkIgnore = (paths: ReadonlyArray) => vcsProcess .run({ @@ -349,13 +348,8 @@ export const make = Effect.gen(function* () { maxOutputBytes: 16 * 1024 * 1024, }) .pipe(Effect.orElseSucceed(() => undefined)); - // Git consults the index for every path it classifies, which costs hundreds of - // milliseconds for a `node_modules`-sized folder. Asking about the folder itself answers - // for every child: `check-ignore` exits 0 only when the folder is ignored and nothing - // under it is tracked, and an ignored folder hides all of its children. Git reads paths as - // pathspecs, so a folder whose path has magic (`:foo`) or glob (`[id]`) characters, or - // one reached through a symlink, can get a different answer per child and still asks git - // about each. + // Check ignored folders once to avoid child index lookups; exit 0 guarantees no tracked descendants. + // Symlinks and pathspec magic still need per-child checks. if ( entries.length > IGNORE_CHECK_CHUNK_SIZE && target.relativePath !== "" && diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 9eb2ba7580490..0cf5524df4e17 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -26,7 +26,6 @@ vi.mock("node:fs/promises", async (importOriginal) => { return { ...actual, realpath: vi.fn(actual.realpath) }; }); -// Temp folders count as known workspaces unless named as unknown. const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({ assertContains: (cwd) => @@ -575,7 +574,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const path = yield* Path.Path; const cwd = yield* makeTempDir; yield* writeTextFile(cwd, ".git/config", "[core]\n"); - // A bare repository named `.bare`, and a linked worktree's git folder inside it. yield* writeTextFile(cwd, ".bare/HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(cwd, ".bare/objects/info/packs"); yield* writeTextFile(cwd, ".bare/refs/heads/main"); @@ -649,7 +647,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const parentDir = yield* makeTempDir; - // A bare repository with a worktree checked out inside it. const bare = path.join(parentDir, "repo.git"); yield* writeTextFile(bare, "HEAD", "ref: refs/heads/main\n"); yield* writeTextFile(bare, "objects/info/packs"); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 4e7c81404256b..7107cd99631a8 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -28,8 +28,7 @@ import { fileHeaderMimeType } from "./fileHeaderMimeType.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; -// Windows also reaches `.git` through trailing dots or spaces, a `:` stream -// suffix, or the 8.3 short name `GIT~1`. Names compare upcased, as NTFS does. +// NTFS reaches .git through trailing dots/spaces, stream suffixes and GIT~1; compare uppercased names. const isGitMetadataName = (segment: string) => { const name = (segment.split(":")[0] ?? "").replace(/[. ]+$/, "").toUpperCase(); return name === ".GIT" || name === "GIT~1"; @@ -133,7 +132,7 @@ export class WorkspaceFileSystem extends Context.Service< } >()("t3/workspace/WorkspaceFileSystem") {} -/** @public Service construction is part of the canonical Effect module API. */ +/** @public */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -168,7 +167,7 @@ export const make = Effect.gen(function* () { } }, catch: () => undefined, - }).pipe(Effect.catch(() => Effect.succeed(undefined))); + }).pipe(Effect.orElseSucceed(() => undefined)); } return { kind: "file", @@ -196,13 +195,8 @@ export const make = Effect.gen(function* () { ); }; - /** - * Git runs config and hooks from its own folders, so a write never lands in one: - * a `.git` entry, or a repository folder under another name such as `.bare`. - * Folders are checked upward to a linked worktree's top, which holds a `.git` - * file, since worktrees can sit inside a bare repository. `absolutePath` must - * be normalized. - */ + // Reject Git folders, including renamed bare repos whose config/hooks can execute; paths must be normalized. + // Stop at linked worktrees' .git files since worktrees can sit inside bare repos. const isInGitFolder = async (absolutePath: string) => { if (absolutePath.split(path.sep).some(isGitMetadataName)) return true; for (let folder = path.dirname(absolutePath); ; folder = path.dirname(folder)) { @@ -215,8 +209,7 @@ export const make = Effect.gen(function* () { }), ), ); - // What git reads as a repository: HEAD, plus objects and refs here or in - // the folder a `commondir` file names, as in a linked worktree's git folder. + // Git recognizes HEAD with commondir or objects/refs as a repository. if (head && (commondir || (objects && refs))) return true; if (dotGit?.isFile() || path.dirname(folder) === folder) return false; } @@ -357,8 +350,7 @@ export const make = Effect.gen(function* () { return yield* Effect.acquireUseRelease( Effect.tryPromise({ - // Non-blocking so a FIFO cannot hang the open; the stat below rejects - // it. Regular files ignore the flag. Windows lacks it. + // Non-blocking opens prevent FIFO hangs before stat rejects them; Windows lacks the flag. try: () => NodeFSP.open( realTargetPath, @@ -451,12 +443,7 @@ export const make = Effect.gen(function* () { ); }); - /** - * Resolves the file a write targets. The deepest existing ancestor resolves to - * its real path before the root check, so neither the file nor the directories - * created for it can follow a symlink out of the root. A dangling symlink is - * rejected rather than written through. - */ + // Resolve existing ancestors and reject dangling links so creation cannot follow symlinks outside root. const resolveWriteTarget = Effect.fn("WorkspaceFileSystem.resolveWriteTarget")(function* ( input: ProjectWriteFileInput, ) { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 3ae5d524611a4..8f2b64cf26a34 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -27,10 +27,7 @@ import type { } from "@t3tools/contracts"; import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; -// fff-node stays external to the CLI bundle because it dlopens a native -// library. A static `import` of an external package is a hard error inside a -// Node single-executable (only built-ins resolve there), so load it through -// `require`, which reads from the real filesystem in every runtime. +// fff-node's native library stays external; single-executables require disk packages instead of importing them. const requireForFff = NodeModule.createRequire(import.meta.url); const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); @@ -257,11 +254,7 @@ function mapContentMatchRanges( })); } -/** - * Whole-word filtering happens after the grep rather than by wrapping the pattern in boundary - * regex: consuming boundaries such as `(?:^|\W)` swallow the separator between adjacent matches - * and widen the reported ranges, and `\b` cannot match punctuation-edged queries at all. - */ +// Post-filter whole words: consuming boundary regexes swallow adjacent separators and widen ranges; \b misses punctuation edges. function isWholeWordRange( line: string, range: { readonly start: number; readonly end: number }, @@ -303,9 +296,7 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( FileFinder.create({ basePath: cwd, disableMmapCache: true, - // Content indexing costs scan CPU and memory, so only the on-demand - // content-search index pays for it; path-only consumers (file tree, - // composer path search, file picker) keep the lightweight index. + // Content indexing costs CPU/memory; path-only consumers use the lightweight index. disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, @@ -490,8 +481,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( )(function* (input) { const { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; - // Grep cursors advance by file, so whole-word post-filtering needs enough - // raw candidates from the current file before moving to the next one. + // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. const rawPageSize = input.wholeWord ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) : input.limit; @@ -555,9 +545,7 @@ function parseWorkspaceSearchIndexKey(key: string): { }; } -/** - * @public Service construction is part of the canonical Effect module API. - */ +/** @public */ export const layer = (key: string) => { const { cwd, variant } = parseWorkspaceSearchIndexKey(key); return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); diff --git a/apps/server/src/worktreesDirectory.ts b/apps/server/src/worktreesDirectory.ts index aef9b8c031c43..e8464eb077f9d 100644 --- a/apps/server/src/worktreesDirectory.ts +++ b/apps/server/src/worktreesDirectory.ts @@ -8,11 +8,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerConfig from "./config.ts"; import * as ServerSettings from "./serverSettings.ts"; -/** - * Null when the setting is not an absolute path on this machine, such as `D:\worktrees` - * configured for a Windows server and synced to a Linux one, or when it is a filesystem root, - * which would make every path on that drive look managed. - */ +/** Null for filesystem roots or settings not absolute on this host, including synced foreign-platform paths. */ export function resolveWorktreesDirectory( setting: string, defaultDir: string, @@ -48,19 +44,14 @@ export function managedWorktreesDirectories( return [...directories]; } -/** - * A thread's path and branch can come from a client, so a missing worktree is - * only recreated where the server would have put it: - * `//`, compared by real path. - */ +// Client paths/branches are untrusted; recreate only real paths matching managed-folder/repo/branch. export const makeIsManagedWorktreePath = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; - // Where git would create `target`: the real path of its nearest existing - // ancestor plus the missing rest, since git follows symlinks on the way. + // Match Git's symlink-following location: nearest real ancestor plus missing path components. const realLocation = (target: string): Effect.Effect => fileSystem.realPath(target).pipe( Effect.catch(() => { @@ -74,8 +65,7 @@ export const makeIsManagedWorktreePath = Effect.gen(function* () { return Effect.fn("worktreesDirectory.isManagedWorktreePath")(function* (worktreePath: string) { // git resolves a relative path against the project, not the server cwd. if (!path.isAbsolute(worktreePath)) return false; - // Server-made paths are normalized. A `..` would be applied after git - // follows a symlink but before realPath does, so the two disagree. + // Require normalized paths; Git and realPath disagree on .. after symlink traversal. if (path.resolve(worktreePath) !== worktreePath) return false; const settings = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => ({ diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 787fdc268f4a4..f0d2e104ca864 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -5,19 +5,26 @@ import { EnvironmentId, ProjectId, ThreadId, + ProviderDriverKind, + ProviderInstanceId, WS_METHODS, + WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, + type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, + type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; @@ -35,6 +42,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CommandOutputHub from "./orchestration-v2/CommandOutputHub.ts"; import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; @@ -68,6 +76,7 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -290,8 +299,101 @@ it.live.each(["thread", "shell"] as const)( }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), ); +it.effect("shell snapshots omit idle children and older settled threads only on request", () => + Effect.gen(function* () { + const shell = ( + id: string, + parent: string | null, + activityRunStatus: "running" | null, + settledDaysAgo: number | null = null, + ) => + ({ + id: ThreadId.make(id), + projectId: "project", + archivedAt: null, + status: activityRunStatus ?? "completed", + activityRunStatus, + pendingRuntimeRequest: null, + pendingBackgroundTasks: [], + settledOverride: settledDaysAgo === null ? null : "settled", + settledAt: + settledDaysAgo === null ? null : DateTime.makeUnsafe(-settledDaysAgo * 86_400_000), + lineage: { + parentThreadId: parent === null ? null : ThreadId.make(parent), + relationshipToParent: parent === null ? null : "subagent", + rootThreadId: ThreadId.make(parent ?? id), + }, + }) as unknown as OrchestrationV2ThreadShell; + const threads = [ + shell("parent", null, null), + shell("idle", "parent", null), + shell("live", "parent", "running"), + ...Array.from({ length: 25 }, (_, index) => shell(`recent-${index}`, null, null, 1)), + shell("old", null, null, 30), + ]; + const firstSnapshot = (input: { + readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; + }) => + subscribeOrchestrationV2Shell(input).pipe( + Effect.flatMap((stream) => stream.pipe(Stream.take(1), Stream.runCollect)), + Effect.map(([item]) => (item?.kind === "snapshot" ? item.snapshot : null)), + Effect.provideService(ThreadManagement.ThreadManagementService, { + readShellSnapshot: () => + Effect.succeed( + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads, + archivedThreads: [], + }), + ), + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedNone, + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideServiceEffect( + ProjectEnrichment.ProjectEnrichmentService, + PubSub.unbounded().pipe( + Effect.map( + (changes) => + ({ + subscribeChanges: PubSub.subscribe(changes), + }) as unknown as ProjectEnrichment.ProjectEnrichmentService["Service"], + ), + ), + ), + ); + + const recent = threads.slice(3, 28).map((thread) => thread.id); + const full = yield* firstSnapshot({}); + assert.deepEqual( + full?.threads.map((thread) => thread.id), + threads.map((thread) => thread.id), + ); + assert.isUndefined(full?.olderSettled); + const withoutChildren = yield* firstSnapshot({ omitIdleSubagents: true }); + assert.deepEqual( + withoutChildren?.threads.map((thread) => thread.id), + ["parent", "live", ...recent, "old"], + ); + const withoutOld = yield* firstSnapshot({ omitIdleSubagents: true, omitOlderSettled: true }); + assert.deepEqual( + withoutOld?.threads.map((thread) => thread.id), + ["parent", "live", ...recent], + ); + assert.deepEqual(withoutOld?.olderSettled?.counts, [ + { projectId: ProjectId.make("project"), count: 1 }, + ]); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), +); + // Build the production RPC layer; unrelated capabilities fail if this test calls them. const layerUnusedConfigRpcServices = Layer.mergeAll( + Layer.mock(DocumentText.DocumentText)({}), Layer.mock(ServerSecretStore.ServerSecretStore)({}), Layer.mock(WorkspacePaths.WorkspacePaths)({}), Layer.mock(TraceDiagnostics.TraceDiagnostics)({}), @@ -339,6 +441,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(GuestShareService.GuestShareService)({}), Layer.mock(ServerLifecycleEvents.ServerLifecycleEvents)({}), Layer.mock(ServerRuntimeStartup.ServerRuntimeStartup)({}), Layer.mock(ServerSelfUpdate.ServerSelfUpdate)({}), @@ -398,6 +501,7 @@ it.effect( Effect.andThen(Deferred.await(finishDiscovery)), Effect.as([]), ), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. resolveFileManagerRevealKind: () => Effect.succeed(undefined), }), Layer.mock(ProviderRegistry.ProviderRegistry)({ @@ -536,6 +640,163 @@ it.effect( ), ); +it.effect("on-demand clients get provider config without workspace snapshots", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const snapshotFor = (cwd: string, checkedAt: string) => ({ + cwd, + checkedAt, + slashCommands: [{ name: `${cwd.slice(1)}-command` }], + skills: [], + }); + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-25T00:00:00.000Z", + models: [], + slashCommands: [{ name: "machine-command" }], + skills: [], + workspaceSnapshots: [ + snapshotFor("/a", "2026-10-25T00:00:00.000Z"), + snapshotFor("/b", "2026-10-25T00:00:00.000Z"), + ], + }; + const providerChanges = yield* Queue.unbounded>(); + const pulled = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + streamChanges: Stream.fromQueue(providerChanges).pipe( + Stream.tap(() => Queue.offer(pulled, undefined)), + ), + refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.make([]), + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { + discard: true, + }) + : response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + {}, + yield* PreviewAutomationBroker.PreviewAutomationBroker, + yield* ServerBrowser.ServerBrowser, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const configRequest = RpcMessage.RequestId("config"); + yield* server.write(0, { + _tag: "Request", + id: configRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); + yield* server.write(0, { _tag: "Ack", requestId: configRequest }); + + // A rescan that only touches a snapshot sends nothing; the next real change does. + yield* Queue.offer(providerChanges, [ + { ...provider, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] }, + ]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + yield* Queue.offer(providerChanges, [{ ...provider, status: "warning" }]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); + assert.equal(update.payload.providers[0]?.status, "warning"); + assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), + [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], + ); + yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same @@ -586,8 +847,7 @@ it.effect("thread resume across compacted history keeps message order and final ) `; } - // Only the event between a's first and newest is deleted. - assert.equal((yield* maintenance.compactEventStore).deletedEventCount, 1); + assert.equal((yield* maintenance.compactEventStore()).deletedEventCount, 1); const items = yield* subscribeOrchestrationV2Thread({ threadId, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e3d05ed705337..fa95a7856fda5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -7,14 +7,12 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Base64 from "effect/encoding/Base64"; import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; @@ -63,6 +61,7 @@ import { OrchestrationV2ThreadLaunchError, type OrchestrationProjectShell, type OrchestrationV2ShellSnapshot, + type OrchestrationV2ThreadStreamItem, type ApplicationStoredEvent, type ProjectEntriesFailure, type ProjectFileFailure, @@ -81,10 +80,12 @@ import { type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, + type ServerProvider, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + SOURCE_CONTROL_MEDIA_ASSET, ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, @@ -117,9 +118,11 @@ import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; +import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; +import * as GuestShareService from "./guest/GuestShareService.ts"; import { archivedShellStreamItemFromThreadShell, buildActiveShellSnapshot, @@ -128,10 +131,12 @@ import { coalesceStoredThreadEvents, composeShellStreamWithEnrichment, dedupeShellEnrichment, + loadOlderSettledThreadsPage, loadShellSnapshotParts, shellStreamItemFromEnrichmentRefresh, shellStreamItemFromThreadShell, shellStreamItemsFromInitialSnapshot, + shellSnapshotForSubscriber, shellStreamItemsFromResumeSnapshot, shouldPublishShellApplicationEvent, skipUnchangedThreadShells, @@ -154,9 +159,11 @@ import { THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, } from "./orchestration-v2/threadHistoryPaging.ts"; import { + makeLiveBodyDeduper, projectDomainEventForWire, projectThreadProjectionForWire, } from "./orchestration-v2/WireProjection.ts"; +import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; import * as OrchestrationEventStore from "./persistence/OrchestrationEventStore.ts"; @@ -209,7 +216,6 @@ import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import { projectMutationOperation } from "./project/ProjectMutation.ts"; -import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; @@ -228,7 +234,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; -import { featureUsage } from "./telemetry/FeatureUsage.ts"; +import { commandFeatureUsage, featureUsage } from "./telemetry/FeatureUsage.ts"; import type { TelemetryOwner } from "./telemetry/Identify.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -314,8 +320,7 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Unbounded, unlike the snapshot: the reveal-kind probe is not shared, so - // a timeout here would cancel a probe that outlasts it every time. + // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -348,8 +353,7 @@ export const withLateEditorConfig = ( return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": return [{ ...current, settings: event.payload.settings }, [event]]; - // Themes and usage-limit sources never ride in a snapshot; clients - // carry their projected values across one. + // Themes and usage-limit sources survive snapshots because snapshots never include them. default: return [current, [event]]; } @@ -358,6 +362,10 @@ export const withLateEditorConfig = ( ); }; +function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); +} + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -569,9 +577,7 @@ const OtherWsRpcGroup = RpcGroup.make(...serverWsRpcs.filter(isOtherWsRpc)); // Replaying each intervening event costs a shell refetch; past this gap a // single O(active-threads) snapshot is cheaper and bounded. const SHELL_RESUME_MAX_GAP = 1_000; -// Row count alone does not bound replay memory: a few events with large tool -// payloads can decode to gigabytes. Before replaying, sum the serialized -// payload bytes of the range in SQL and reset with a snapshot past this budget. +// Row counts do not bound large tool payloads; sum stored JSON bytes in SQL and snapshot above budget. const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; function toAuthAccessStreamEvent( @@ -654,8 +660,7 @@ function readClientConnectionOrigin( }; } -// Client telemetry stays in this socket's RPC layer. It must not become a -// server-global "current client" because several client types can connect at once. +// Keep telemetry per socket; several client types can connect simultaneously. function readClientAnalyticsProps(request: HttpServerRequest.HttpServerRequest) { const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -785,8 +790,14 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; readonly acceptCompactTurnItems?: boolean; + readonly acceptDedupedBodies?: boolean; + readonly acceptCompactIds?: boolean; }) { const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const dedupeBodies = input.acceptDedupedBodies === true; + // Replay, snapshot, and live tail share one deduper: it must see every delivered record. + const withLiveDedupe = (stream: Stream.Stream) => + dedupeBodies ? Stream.suspend(() => Stream.map(stream, makeLiveBodyDeduper())) : stream; const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; yield* Effect.annotateCurrentSpan({ @@ -833,9 +844,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs afterSequence, throughSequence, limit: THREAD_RESUME_MAX_REPLAY_EVENTS + 1, - // A row a newer build wrote (e.g. after a downgrade) must not fail - // every resubscribe. The live tail resumes from `highWater`, so a - // dropped row never hides later events. + // Downgrades can leave unknown rows; skip them and resume live from highWater so later events survive. skipUnknownEventTypes: true, }) .pipe( @@ -885,21 +894,26 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs ? buildBoundedThreadStreamSnapshot({ ...snapshot, compactTurnItems: input.acceptCompactTurnItems === true, + dedupeBodies, + compactIds: input.acceptCompactIds === true, }) - : { - kind: "snapshot" as const, - snapshotSequence, - projection: projectThreadProjectionForWire(snapshot.projection), - }; - return Stream.concat( - Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), - eventStreamFrom(snapshotSequence), + : (() => { + const projection = projectThreadProjectionForWire(snapshot.projection); + return { + kind: "snapshot" as const, + snapshotSequence, + projection: dedupeBodies ? dedupeSnapshotBodies(projection) : projection, + }; + })(); + return withLiveDedupe( + Stream.concat( + Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), + eventStreamFrom(snapshotSequence), + ), ); }); - // The event sink subscribes to live events before reading the persisted - // tail, so no event published during the replay window is lost; overlapping - // events are deduped by sequence on the client. + // Subscribe before reading persisted events to retain concurrent publishes; clients dedupe overlap by sequence. if (input.afterSequence !== undefined) { const highWater = yield* applicationEvents.latestAgentSequence(input.threadId).pipe( Effect.mapError( @@ -931,8 +945,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // Bound stored JSON before decoding, then check projected event - // size separately. Neither byte count is a bound on process memory. + // Bound stored and projected payload bytes separately; neither bounds process memory. if ( stats.eventCount > THREAD_RESUME_MAX_REPLAY_EVENTS || !isThreadReplayRawPayloadSafe(stats.rawPayloadBytes) @@ -950,8 +963,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs }), ), ); - // A retained creation can belong to a thread already deleted. - // Only replace its bounded replay when a snapshot can exist. + // Retained creation events may describe deleted threads; replace replay only if a snapshot exists. if (shell !== null) return yield* snapshotThenLive(); } const replay = yield* loadReplayThrough(input.afterSequence, highWater); @@ -964,9 +976,11 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (plan.mode === "snapshot") { return yield* snapshotThenLive(); } - return Stream.concat( - Stream.concat(rpcInitialItems(replay), completionMarker), - eventStreamFrom(highWater), + return withLiveDedupe( + Stream.concat( + Stream.concat(rpcInitialItems(replay), completionMarker), + eventStreamFrom(highWater), + ), ); } @@ -978,6 +992,8 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc function* (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; + readonly omitIdleSubagents?: boolean; + readonly omitOlderSettled?: boolean; }) { const sql = yield* SqlClient.SqlClient; const threadManagement = yield* ThreadManagementService.ThreadManagementService; @@ -1014,14 +1030,18 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc latestSequence: applicationEvents.latestApplicationSequence, }), ); - subagentThreadIds = new Set( - base.threads - .filter((thread) => thread.lineage.relationshipToParent === "subagent") - .map((thread) => thread.id), - ); + const forSubscriber = shellSnapshotForSubscriber(base, { + omitIdleSubagents: input.omitIdleSubagents === true, + omitOlderSettled: input.omitOlderSettled === true, + now: yield* DateTime.now, + }); + subagentThreadIds = forSubscriber.subagentThreadIds; const enriched = yield* enrichProjectShells(base.projects); return { - snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, + snapshot: { + ...forSubscriber.snapshot, + projects: enriched.projects, + } as OrchestrationV2ShellSnapshot, resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, }; }); @@ -1117,11 +1137,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc ), ); - // Always attach the enrichment subscription before the first load so - // completions that race HTTP snapshot fetch still push a refresh. We still - // emit one compact metadata refresh up front: getAvailable may have been - // cold on the HTTP path (null identity), and enrichment PubSub events - // published before this subscribe attached are dropped. + // Subscribe before loading for racing enrichments; initial refresh covers pre-subscription completions and cold HTTP snapshots. const completionMarker = input.requestCompletionMarker === true ? Stream.make({ kind: "synchronized" as const }) @@ -1146,9 +1162,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots, }), ); - // Initial unmarked (+ optional same-load marked) always drains first. - // Enrichment merges only with the post-prefix tail so a ready marked - // refresh cannot interleave before the authoritative initial frame. + // Drain initial frames before merging enrichment with live events so marked refreshes cannot precede the initial frame. const completionThenLive = (afterSequence: number) => Stream.concat(completionMarker, liveFrom(afterSequence)); @@ -1233,6 +1247,7 @@ export const layerWsRpc = ( | FileSystem.FileSystem | ServerConfig.ServerConfig | ServerSettings.ServerSettingsService + | DocumentText.DocumentText >(); const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; const projectStore = yield* ProjectStore.ProjectStoreV2; @@ -1246,18 +1261,28 @@ export const layerWsRpc = ( const analytics = yield* AnalyticsService.AnalyticsService; const originProps = clientAnalyticsProps; const recordClientCommandAnalytics = (command: OrchestrationV2Command) => { - switch (command.type) { - case "message.dispatch": - return analytics.record("client.turn.requested", originProps).pipe(Effect.ignore); - default: - return Effect.void; - } + const usage = commandFeatureUsage(command); + return Effect.all( + [ + command.type === "message.dispatch" + ? analytics.record("client.turn.requested", originProps) + : Effect.void, + usage === undefined + ? Effect.void + : analytics.record("feature.used", { + ...usage, + ...(originProps.surface === undefined ? {} : { surface: originProps.surface }), + }), + ], + { discard: true }, + ).pipe(Effect.ignore); }; const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; const secretRequests = yield* SecretRequests.SecretRequests; + const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; @@ -1282,7 +1307,6 @@ export const layerWsRpc = ( ); const usage = yield* UsageService.UsageService; const usageLimitSources = yield* UsageLimitSources.UsageLimitSources; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; const repositoryIdentityResolver = @@ -1352,8 +1376,7 @@ export const layerWsRpc = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const relayClient = yield* RelayClient.RelayClient; - // A webhook URL starts agent runs, so only sessions that may operate - // see it; read-only sessions still see the task itself. + // Webhook URLs start agents; expose them only to operate-scoped sessions while keeping tasks readable. const withVisibleWebhookUrls = (result: ScheduledTaskListResult): ScheduledTaskListResult => currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result @@ -1381,16 +1404,14 @@ export const layerWsRpc = ( }), ), ); - return yield* Option.match(project, { - onNone: () => - Effect.fail( - new AcpRegistryOperationError({ - reason: "project_not_found", - message: `Project ${projectId} was not found.`, - }), - ), - onSome: Effect.succeed, - }); + return yield* Effect.fromOption( + project, + () => + new AcpRegistryOperationError({ + reason: "project_not_found", + message: `Project ${projectId} was not found.`, + }), + ); }); const acpSessionManager = Effect.fn("ws.acpRegistry.sessionManager")(function* ( @@ -1704,12 +1725,16 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; + readonly workspaceSnapshotsOnDemand: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const commandProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = options.workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); @@ -1766,6 +1791,8 @@ export const layerWsRpc = ( }, settings, shellResumeCompletionMarker: true, + shellThreadFamilies: true, + shellOlderSettled: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, threadFind: true, @@ -1812,6 +1839,26 @@ export const layerWsRpc = ( ), ); + // Rows the shell can omit, read with the same snapshot sequence the shell uses. + const readOmittableShellRows = Effect.fn("ws.orchestrationV2.readOmittableShellRows")( + function* ( + options: { readonly familyOf: ThreadId } | { readonly worktreePath: string }, + message: string, + ) { + const { threads, snapshotSequence } = yield* loadShellSnapshotParts({ + sql, + readThreads: threadManagement.readShellSnapshot({ location: "active", ...options }), + listProjects: Effect.succeed([]), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => new OrchestrationV2GetShellSnapshotError({ message, cause }), + ), + ); + return { snapshotSequence, threads: threads.threads }; + }, + ); + const subscribeOrchestrationV2ArchivedShell = Effect.fn( "ws.orchestrationV2.subscribeArchivedShell", )(function* () { @@ -1889,7 +1936,9 @@ export const layerWsRpc = ( ).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => recordClientCommandAnalytics(command)), + Effect.tap((result) => + result.replayed ? Effect.void : recordClientCommandAnalytics(command), + ), Effect.map((result) => ({ sequence: result.sequence })), Effect.mapError((cause) => { const detail = userFacingDispatchErrorMessage(cause); @@ -1947,7 +1996,15 @@ export const layerWsRpc = ( .searchThreadStream(input) .pipe(Stream.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => - threadSearch.search(input).pipe( + ThreadSearch.searchThreadsWithOlderSettledRows({ + request: input, + search: threadSearch.search, + searchOlderSettled: threadSearch.searchOlderSettled, + sql, + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( Effect.mapError( (cause) => new OrchestrationSearchThreadsError({ @@ -1958,23 +2015,44 @@ export const layerWsRpc = ( ), [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: (_input) => getOrchestrationV2ArchivedShellSnapshot, + [ORCHESTRATION_V2_WS_METHODS.getThreadFamily]: (input) => + readOmittableShellRows({ familyOf: input.threadId }, "Failed to load the thread family"), + [ORCHESTRATION_V2_WS_METHODS.getOlderSettledThreads]: (input) => + loadOlderSettledThreadsPage({ + sql, + listThreads: threadManagement.listOlderSettledThreads(input), + readThreads: (threadIds) => + threadManagement.readShellSnapshot({ location: "active", threadIds }), + latestSequence: applicationEvents.latestApplicationSequence, + }).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetShellSnapshotError({ + message: "Failed to load older settled threads", + cause, + }), + ), + ), + [ORCHESTRATION_V2_WS_METHODS.getWorktreeThreadIds]: (input) => + readOmittableShellRows( + { worktreePath: input.worktreePath }, + "Failed to load the worktree's threads", + ).pipe(Effect.map(({ threads }) => ({ threadIds: threads.map((thread) => thread.id) }))), [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen( - // Pre-pagination clients still call this compatibility endpoint. - // Keep stale clients from materializing an unbounded transcript. + // Bound compatibility snapshots so pre-pagination clients cannot materialize unlimited transcripts. threadManagement .getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, }) .pipe( - Effect.map((snapshot) => - projectThreadProjectionForWire( + Effect.map( + (snapshot) => buildBoundedThreadProjection({ - projection: snapshot.projection, + projection: projectThreadProjectionForWire(snapshot.projection), snapshotSequence: snapshot.snapshotSequence, }).projection, - ), ), Effect.mapError( (cause) => @@ -2032,17 +2110,23 @@ export const layerWsRpc = ( }).pipe(Effect.provide(intakeContext)), ) .pipe( - Effect.tap(() => - analytics - .record("client.thread.started", originProps) - .pipe( - Effect.andThen( - input.initialMessage === undefined - ? Effect.void - : analytics.record("client.turn.requested", originProps), - ), - Effect.ignore, - ), + // A resumed launch is a retry of one already counted. + Effect.tap((result) => + result.resumed + ? Effect.void + : analytics + .record("client.thread.started", { + ...originProps, + workspaceStrategy: input.workspaceStrategy.type, + }) + .pipe( + Effect.andThen( + input.initialMessage === undefined + ? Effect.void + : analytics.record("client.turn.requested", originProps), + ), + Effect.ignore, + ), ), Effect.map((result) => ({ ...result, @@ -2125,6 +2209,10 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "scheduled_task.id": input.id }).pipe( Effect.andThen(scheduledTasks.rotateWebhookToken(input)), ), + [WS_METHODS.guestShareCreate]: (input) => + Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( + Effect.andThen(guestShares.create(input)), + ), [WS_METHODS.secretsAnswerRequest]: (input) => Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(secretRequests.answer(input)), @@ -2138,7 +2226,8 @@ export const layerWsRpc = ( Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), - [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), + [WS_METHODS.serverGetConfig]: (_input) => + loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -2267,8 +2356,7 @@ export const layerWsRpc = ( { concurrency: "unbounded", discard: true }, ); } - // Awaited, not forked: the RPC scope closes on return and would - // interrupt a fork before the hub answered. + // Await refresh; RPC scope closure would interrupt a fork before the hub answered. if (input.instanceId === undefined) { yield* usageLimitSources.refresh; } @@ -2308,7 +2396,29 @@ export const layerWsRpc = ( providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } - return { providers }; + if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const commandProviders = + input.usageLimitsCommand === true + ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) + : providers; + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) { + return { providers: withoutWorkspaceSnapshots(commandProviders) }; + } + return { + providers: commandProviders + .filter((provider) => provider.instanceId === instanceId) + .map((provider) => ({ + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd === cwd, + ), + })), + }; }), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), @@ -2327,29 +2437,23 @@ export const layerWsRpc = ( ) ?? projection.providerThreads.at(-1); const providerSessionId = providerThread?.providerSessionId ?? null; if (providerThread === undefined || providerSessionId === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "No provider session has run in this thread yet.", - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "No provider session has run in this thread yet.", + }); } const runtime = Option.getOrNull(yield* providerSessionsV2.get(providerSessionId)); if (runtime === null) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: "The provider session is no longer running. Send a message first.", - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: "The provider session is no longer running. Send a message first.", + }); } if (runtime.uploadFeedback === undefined) { - return yield* Effect.fail( - new ProviderUploadFeedbackError({ - threadId: input.threadId, - cause: `Provider '${runtime.driver}' does not support feedback uploads.`, - }), - ); + return yield* new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause: `Provider '${runtime.driver}' does not support feedback uploads.`, + }); } return yield* runtime.uploadFeedback({ providerThread, @@ -2783,7 +2887,7 @@ export const layerWsRpc = ( input.resource._tag === "attachment" || input.resource._tag === "native-app-icon" || input.resource._tag === "tool-output-image" || - input.resource._tag === "github-media" || + input.resource._tag === SOURCE_CONTROL_MEDIA_ASSET || // A network path is served only from the thread's project, looked up below. (input.resource._tag === "media-file" && path.isAbsolute(input.resource.path) && @@ -2814,8 +2918,7 @@ export const layerWsRpc = ( resource: input.resource, }); } - // A cloned project exists before its files do. Clients ask again - // when the clone lands (see createProjectFaviconUrlAtomFamily). + // Clone projects precede their files; clients retry asset URLs after clone completion. const clone = yield* projectCloneTracker.get(project.value.projectId); return yield* issueAssetUrl({ resource: input.resource, @@ -2865,7 +2968,8 @@ export const layerWsRpc = ( worktreeSetupTracker .cancel(input.threadId) .pipe(Effect.map((cancelled) => ({ cancelled }))), - [WS_METHODS.vcsRefreshStatus]: (input) => vcsStatusBroadcaster.refreshStatus(input.cwd), + [WS_METHODS.vcsRefreshStatus]: (input) => + vcsStatusBroadcaster.refreshStatus(input.cwd, { localOnly: input.localOnly ?? false }), [WS_METHODS.vcsPull]: (input) => gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ @@ -3025,10 +3129,14 @@ export const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; + const workspaceSnapshotsOnDemand = input.workspaceSnapshotsOnDemand === true; // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + workspaceSnapshotsOnDemand, + }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -3040,9 +3148,7 @@ export const layerWsRpc = ( })), ); const providerStatuses = Stream.zipLatestWith( - // The registry stream carries changes only. Seed it with the current - // providers so a source refresh that lands before any provider change - // still pairs up and reaches the client. + // Seed the changes-only stream so early source refreshes pair with current providers. Stream.concat( Stream.fromEffect(providerRegistry.getProviders), providerRegistry.streamChanges, @@ -3052,13 +3158,17 @@ export const layerWsRpc = ( usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, ), ), - (providers, sources) => - usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + (providers, sources) => { + const commandProviders = usageLimitsCommand + ? withUsageLimitsCommands(providers, sources) + : providers; + // Stripped before the de-dupe below, so snapshot-only changes send nothing. + return workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; + }, ).pipe( - // Both sides replay their current value, so the first pairing normally - // repeats the snapshot the client already holds. Compare against that - // snapshot rather than dropping blindly: a refresh that landed between - // the snapshot and the subscription still goes out. + // Compare initial paired values with the snapshot; blindly dropping them loses changes between snapshot and subscription. (updates) => Stream.concat(rpcInitialItems([config.providers]), updates), Stream.changesWith( (previous, next) => JSON.stringify(previous) === JSON.stringify(next), @@ -3071,10 +3181,7 @@ export const layerWsRpc = ( })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); - // Gated on the subscriber's capability flag because an - // already-shipped client decodes this stream against the old - // event union and its whole config subscription dies on an - // unknown member. + // Gate capability-specific events; older clients reject them and lose their config subscription. const environmentThemeUpdates = input.environmentThemes === true ? environmentTheme.streamChanges.pipe( @@ -3196,8 +3303,7 @@ export const layerWsRpc = ( }), ); -// RpcServer's default sends a socket-level Defect frame instead, and the client -// ends every pending request on the socket with it. +// Default socket-level Defect frames end every pending client request. export const WS_RPC_SERVER_OPTIONS = { disableTracing: true, disableFatalDefects: true, @@ -3230,7 +3336,7 @@ export const layer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const analytics = yield* AnalyticsService.AnalyticsService; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + const session = yield* serverAuth.authenticateWebSocketUpgrade(request, "rpc").pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( EnvironmentAuth.serverAuthCredentialReason(error), @@ -3244,7 +3350,6 @@ export const layer = Layer.unwrap( const clientOrigin = readClientConnectionOrigin(request); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); - yield* analytics.record("client.connected", clientAnalyticsProps); yield* analytics.recordOwners(readClientOwners(request), "client"); const rpcWebSocketHttpEffect = yield* Effect.gen(function* () { const { protocol, httpEffect } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket; @@ -3272,7 +3377,6 @@ export const layer = Layer.unwrap( ), Effect.forkScoped, ); - // @effect-diagnostics-next-line returnEffectInGen:off return httpEffect; }).pipe( Effect.provide( @@ -3284,15 +3388,13 @@ export const layer = Layer.unwrap( serverBrowser, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), - // Request fibers run in the handlers' context, so this reporter sees - // their defects, not the rest of the server's. + // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), - // One server-lifetime service means clients share the same PR caches, and a WS - // mutation invalidates the HTTP diff cache that every client reads from. + // Share server-lifetime PR caches so WS mutations invalidate every client's HTTP diffs. Layer.provide(Layer.succeed(PullRequestService.PullRequestService, pullRequests)), Layer.provide(OtlpEndpointCheck.layer), Layer.provide( diff --git a/apps/server/src/zipArchive.ts b/apps/server/src/zipArchive.ts index 33c8099473889..6711fbd3f5ee8 100644 --- a/apps/server/src/zipArchive.ts +++ b/apps/server/src/zipArchive.ts @@ -3,9 +3,7 @@ import * as Effect from "effect/Effect"; import type * as NodeStream from "node:stream"; import * as Yauzl from "yauzl"; -/** - * Close the scope to close the archive. - */ +/** Closing the scope closes the archive. */ export const openZipArchive = Effect.fn("zipArchive.open")(function* ( archivePath: string, makeError: (detail: string, cause: unknown) => E, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index e4e1b71c61520..907a59e7ee35b 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -19,9 +19,7 @@ const repoEnv = loadRepoEnv(); const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version) ? "nightly" : "latest"; -// Sent with every analytics event. Release workflows set it for pingdotgg/t3code -// builds only, so local builds and forks report "dev". A typo fails the build -// instead of mislabeling events. +// Official release workflows set the telemetry channel; local builds and forks use "dev". const TELEMETRY_CHANNELS = ["stable", "nightly", "preview", "dev"]; const telemetryChannel = process.env.T3CODE_TELEMETRY_CHANNEL?.trim() || "dev"; if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { @@ -30,10 +28,7 @@ if (!TELEMETRY_CHANNELS.includes(telemetryChannel)) { ); } -// `build:exe` wraps the same bundle in a Node single-executable. tsdown's exe -// step refuses multi-chunk output and counts the sourcemap as a chunk, and the -// executable needs a host Node that supports `--build-sea` (25.7+), so this is -// a separate mode rather than a second entry in the default build. +// tsdown SEA needs one chunk (sourcemaps count) and Node >=25.7, so package separately. const packExecutable = process.env.T3CODE_PACK_EXE === "1"; const SEA_TARGETS = { "darwin-arm64": { platform: "darwin", arch: "arm64" }, @@ -67,9 +62,11 @@ const packExecutableTargets = packExecutableTarget // The root config's `projects` route repo-root runs to package configs, // including this one. Inheriting them would make this config a container. const { projects: _rootTestProjects, ...baseTestConfig } = baseConfig.test ?? {}; +// Workspace cache configuration is invalid on a package config. +const { run: _rootRunConfig, ...basePackageConfig } = baseConfig; export default mergeConfig( - { ...baseConfig, test: baseTestConfig }, + { ...basePackageConfig, test: baseTestConfig }, defineConfig({ run: { tasks: { @@ -81,8 +78,7 @@ export default mergeConfig( }, }, pack: { - // The executable embeds one entry; the history worker becomes a hidden - // subcommand there instead of a sibling script. + // SEA embeds one entry; expose the history worker as a hidden subcommand. entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, @@ -93,17 +89,13 @@ export default mergeConfig( fileName: "t3", outDir: "dist-exe", ...(packExecutableTargets ? { targets: packExecutableTargets } : {}), - // Node's SEA docs: `import()` does not work when useCodeCache is - // true, and the server reaches several modules that way. + // Node SEA useCodeCache breaks import(), which the server needs. seaConfig: { useCodeCache: false }, }, } : {}), deps: { - // `neverBundle` forces the native packages out: returning false from - // `alwaysBundle` only means "no opinion", so a transitive dependency - // would still be bundled — which silently inlined native loaders such - // as node-gyp-build, losing native acceleration. + // alwaysBundle=false has no opinion; neverBundle must retain transitive native acceleration. alwaysBundle: shouldBundleCliDependency, neverBundle: (id: string) => isExternalCliDependency(id), onlyBundle: false, @@ -137,12 +129,13 @@ export default mergeConfig( }, }, test: { - // The server suite exercises sqlite, git, temp worktrees, and orchestration - // runtimes heavily. Running files in parallel introduces load-sensitive flakes. + // Parallel sqlite/git/worktree/orchestration tests introduce load-sensitive flakes. fileParallelism: false, sequence: { sequencer: WeightedShardSequencer }, - // Appended to the root setup, which mergeConfig concatenates. - setupFiles: ["./src/testUtils/gitConfig.setup.ts"], + setupFiles: [ + "./src/testUtils/gitConfig.setup.ts", + "./src/testUtils/managedServiceEnv.setup.ts", + ], hookTimeout: 120_000, testTimeout: 120_000, }, diff --git a/apps/web/index.html b/apps/web/index.html index bd4a3d6d5f8ac..13518deb6e5d1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -122,8 +122,6 @@ }, }, }; - // Keep the built-in mode list in sync with the runtime so the selected - // T3 Chat palette is visible before React has mounted. const BUILT_IN_THEME_MODES = Object.fromEntries( Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [ id, @@ -145,8 +143,6 @@ "t3-ember", "t3-iris", ]; - // Update every theme-color meta so any element another layer added - // carries the resolved color too. const setThemeColor = (color) => { for (const meta of document.querySelectorAll('meta[name="theme-color"]')) { meta.setAttribute("content", color); @@ -219,9 +215,6 @@ try { const storedTheme = window.localStorage.getItem("t3code:theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - // Older builds stored the dark T3 Chat palette as its own theme id - // and every maintainer theme under a t3- prefix; keep those - // preferences readable. Mirrors the runtime's legacy alias table. const LEGACY_THEME_IDS = { "t3-chat-dark": "t3-chat", "t3-grove": "grove", @@ -242,7 +235,6 @@ (mode) => mode === customTheme.appearance || customTheme.variants?.[mode], ) : null); - // Mirrors the runtime's isKnownThemePreference. const isKnownTheme = storedTheme === "light" || storedTheme === "dark" || @@ -256,8 +248,6 @@ THEME_APPEARANCE_MODE_STORAGE_KEY, ); const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY); - // Mirrors the runtime's getThemePreferenceMode fallback: a bare - // "light"/"dark" preference names its own mode. const preferenceMode = theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance); const appearanceMode = isThemePreferenceMode(storedAppearanceMode) @@ -296,9 +286,7 @@ halfCustomTheme = halfCustom; } } - } catch { - // A malformed mix degrades to the base preference. - } + } catch {} const themeMode = !isThemed ? null : appearanceMode === "system" @@ -385,13 +373,10 @@ } setThemeColor(chromeColor); } catch { - // Mirror the runtime's storage-failure fallback: follow the OS. let prefersDark = true; try { prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - } catch { - // Keep the dark default when even matchMedia is unavailable. - } + } catch {} delete document.documentElement.dataset.themeId; delete document.documentElement.dataset.themeSelected; document.documentElement.classList.toggle("dark", prefersDark); @@ -413,7 +398,8 @@ body { background: #ffffff; color: #262626; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Inter Variable", "Segoe UI", system-ui, sans-serif; } html.dark body { @@ -453,29 +439,234 @@ object-fit: contain; } + /* App CSS locks body and #root scrolling once main loads, so the error screen scrolls itself. */ + #boot-shell:has(> #boot-error) { + height: 100%; + overflow-x: hidden; + overflow-y: auto; + } + #boot-error { - display: grid; - justify-items: center; - gap: 16px; - max-width: 600px; - padding: 24px; - text-align: center; + --boot-error-fg: #262626; + --boot-error-bg: #ffffff; + --boot-error-line: color-mix(in srgb, currentColor 12%, transparent); + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: flex-start; + width: min(400px, calc(100% - 48px)); + padding: clamp(24px, 22vh, 220px) 0 24px; + font-size: 13px; + line-height: 20px; } - #boot-error p { + html.dark #boot-error { + --boot-error-fg: #f5f5f5; + --boot-error-bg: #0a0a0a; + } + + html[data-theme-selected="true"] #boot-error { + --boot-error-fg: var(--boot-foreground); + --boot-error-bg: var(--boot-background); + } + + #boot-error h1 { margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 22px; + } + + #boot-error p { + margin: 4px 0 0; + opacity: 0.7; overflow-wrap: anywhere; + text-wrap: pretty; + } + + #boot-error code, + #boot-error pre { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; + overflow-wrap: anywhere; + } + + #boot-error a { + color: inherit; + text-decoration-line: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 3px; + } + + #boot-error a:hover { + text-decoration-color: currentColor; } #boot-error button { - padding: 8px 16px; - border: 1px solid currentColor; - border-radius: 4px; + height: 32px; + padding: 0 14px; + border: 1px solid var(--boot-error-line); + border-radius: 6px; background: transparent; color: inherit; font: inherit; + font-weight: 500; cursor: pointer; } + + #boot-error button[aria-disabled="true"] { + opacity: 0.6; + cursor: default; + } + + #boot-error :is(button, a, summary, pre):focus-visible { + outline: 2px solid var(--boot-error-fg); + outline-offset: 2px; + } + + #boot-error-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + } + + #boot-error #boot-error-reload { + border-color: var(--boot-error-fg); + background: var(--boot-error-fg); + color: var(--boot-error-bg); + } + + #boot-error-reload:hover { + opacity: 0.9; + } + + #boot-error #boot-error-update-note { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + } + + #boot-error details { + width: 100%; + margin-top: 32px; + } + + #boot-error summary { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 4px; + list-style: none; + opacity: 0.7; + cursor: pointer; + } + + #boot-error summary::-webkit-details-marker { + display: none; + } + + #boot-error summary::before { + content: ""; + width: 5px; + height: 5px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + } + + #boot-error details[open] summary::before { + transform: rotate(45deg); + } + + #boot-error summary:hover, + #boot-error details[open] summary { + opacity: 1; + } + + #boot-error details > :not(summary) { + display: grid; + gap: 8px; + margin-top: 20px; + } + + #boot-error details > a { + display: block; + width: fit-content; + } + + #boot-error details p { + margin: 0; + font-size: 12px; + line-height: 18px; + } + + #boot-error-command { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 4px 4px 10px; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + } + + #boot-error-command code { + flex: 1; + min-width: 0; + opacity: 0.8; + } + + #boot-error .boot-error-icon { + display: grid; + flex: none; + place-items: center; + min-width: 24px; + height: 24px; + padding: 0 4px; + border: 0; + border-radius: 4px; + font-size: 12px; + font-weight: 400; + opacity: 0.7; + } + + #boot-error .boot-error-icon:hover { + opacity: 1; + background: color-mix(in srgb, currentColor 8%, transparent); + } + + #boot-error-details { + max-height: 200px; + margin: 0; + padding: 10px 12px; + overflow: auto; + border: 1px solid var(--boot-error-line); + border-radius: 6px; + font-size: 11px; + line-height: 16px; + white-space: pre-wrap; + opacity: 0.8; + } + + #boot-error-report { + display: flex; + align-items: center; + gap: 16px; + } + + #boot-error-report button { + height: 28px; + padding: 0 10px; + } + + .boot-error-sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } T3 Code (Beta) diff --git a/apps/web/package.json b/apps/web/package.json index 2ad03efe77e15..27565feebbadd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,10 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@betteroffice/docx": "0.4.3", + "@betteroffice/fonts": "0.4.0", + "@betteroffice/pptx": "0.2.0", + "@betteroffice/xlsx": "0.3.0", "@clerk/clerk-js": "catalog:", "@clerk/electron": "catalog:", "@clerk/react": "catalog:", @@ -22,6 +26,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", + "@fontsource-variable/inter": "^5.3.0", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", @@ -56,6 +61,8 @@ "culori": "^4.0.2", "dompurify": "^3.4.16", "effect": "catalog:", + "entities": "8.1.0", + "fast-xml-parser": "5.7.3", "hast-util-to-html": "^9.0.5", "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", @@ -66,6 +73,7 @@ "lucide": "^0.564.0", "lucide-react": "^0.564.0", "morphicons": "^1.7.1", + "pdfjs-dist": "6.4.299", "pele": "^1.8.0", "react": "catalog:", "react-dom": "catalog:", @@ -73,7 +81,10 @@ "remark-gfm": "catalog:", "remark-math": "6.0.0", "remark-parse": "catalog:", + "saxes": "6.0.0", + "sql.js": "1.15.0", "tailwind-merge": "^3.4.0", + "tiff": "7.1.3", "unbash": "^5.0.0", "unified": "catalog:", "use-sync-external-store": "^1.6.0", @@ -89,6 +100,7 @@ "@types/mdast": "^4.0.4", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/sql.js": "1.4.11", "@types/use-sync-external-store": "^0.0.6", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.1.2", diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index cc3b9e5d3b453..5e5b5ff16be90 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -11,11 +11,7 @@ import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { appAtomRegistry, AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; -/** - * The Electron browser host intentionally sits outside the router so its - * webviews survive route transitions, but it must share the same atom registry - * as routed UI. - */ +// Keep webviews outside the router but inside its atom registry so route changes preserve them. export function AppRoot({ router }: { readonly router: AppRouter }) { useEffect( () => diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx new file mode 100644 index 0000000000000..3356d51b98374 --- /dev/null +++ b/apps/web/src/appInit.tsx @@ -0,0 +1,157 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { ClientSettings } from "@t3tools/contracts/settings"; +import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; + +import { applyAppearanceContrast } from "./appearanceContrast"; +import { applyAppearanceFontVariables } from "./appearanceFonts"; +import { retainThreadOverlaysForThread } from "./components/chat/threadOverlayRetention"; +import { startBackgroundThreadAnnouncements } from "./components/BackgroundThreadAnnouncer"; +import { startSshPasswordPromptQueue } from "./components/desktop/SshPasswordPromptDialog"; +import { startHostFreezeToasts } from "./components/HostFreezeToastCoordinator"; +import { startLegacyThreadMigrationToast } from "./components/LegacyThreadMigrationToast"; +import { startPermissionUpdateNotices } from "./components/PermissionUpdateNotice"; +import { startAgentOpenedPreviews } from "./components/preview/agentOpenedPreviews"; +import { startMainAppLocationTracking } from "./components/sidebar/mainAppLocation"; +import { startDesktopUpdateLaunchNotice } from "./components/sidebar/updateEverythingRun"; +import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; +import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; +import { resolveServerBackedAppDisplayName } from "./branding.logic"; +import { isElectron } from "./env"; +import { useComposerDraftStore } from "./composerDraftStore"; +import { startDefaultThemeAdoption } from "./hooks/useDefaultTheme"; +import { + getClientSettings, + startModelPreferencesMigration, + subscribeClientSettings, +} from "./hooks/useSettings"; +import { startThreadVisitedMigration } from "./hooks/useThreadVisitedMigration"; +import { + syncDocumentElectronPlatformClasses, + syncDocumentWindowControlsOverlayClass, +} from "./lib/windowControlsOverlay"; +import { startProviderAuthDelivery } from "./providerAuthDelivery"; +import { getRouter, type AppRouter } from "./router"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { primaryServerConfigAtom } from "./state/server"; +import { resolveThreadRouteTarget } from "./threadRoutes"; + +/** Writes appearance settings onto the document, touching only the groups that changed. */ +function startClientAppearance(): void { + const root = document.documentElement; + let applied: ClientSettings | null = null; + const apply = () => { + const settings = getClientSettings(); + const previous = applied; + applied = settings; + if (previous?.diffColorScheme !== settings.diffColorScheme) { + root.dataset.diffColorScheme = settings.diffColorScheme; + } + if (previous?.chatWidth !== settings.chatWidth) { + root.dataset.chatWidth = settings.chatWidth; + } + if (previous?.redesignEnabled !== settings.redesignEnabled) { + root.toggleAttribute("data-redesign", settings.redesignEnabled); + } + if (previous?.appearanceContrast !== settings.appearanceContrast) { + applyAppearanceContrast(root, settings.appearanceContrast); + } + if (previous?.glassOpacity !== settings.glassOpacity) { + root.style.setProperty("--glass-opacity", `${settings.glassOpacity}%`); + if (settings.glassOpacity === 100) { + root.style.setProperty("--glass-blur", "0px"); + } else { + root.style.removeProperty("--glass-blur"); + } + } + if ( + previous?.fontFamilySans !== settings.fontFamilySans || + previous.fontFamilyCode !== settings.fontFamilyCode || + previous.fontFamilyComposer !== settings.fontFamilyComposer || + previous.fontSizeInterface !== settings.fontSizeInterface || + previous.fontSizePrompt !== settings.fontSizePrompt || + previous.fontSizeCode !== settings.fontSizeCode || + previous.fontSmoothing !== settings.fontSmoothing + ) { + applyAppearanceFontVariables(root, { + sans: settings.fontFamilySans, + code: settings.fontFamilyCode, + composer: settings.fontFamilyComposer, + sizeInterface: settings.fontSizeInterface, + sizePrompt: settings.fontSizePrompt, + sizeCode: settings.fontSizeCode, + smoothing: settings.fontSmoothing, + }); + } + }; + subscribeClientSettings(apply); + apply(); +} + +function startDocumentTitle(): void { + appAtomRegistry.subscribe( + primaryServerConfigAtom, + (config) => { + document.title = resolveServerBackedAppDisplayName({ + baseName: APP_BASE_NAME, + fallbackDisplayName: APP_DISPLAY_NAME, + fallbackStageLabel: APP_STAGE_LABEL, + primaryServerVersion: config?.environment.serverVersion ?? null, + }); + }, + { immediate: true }, + ); +} + +/** Chat overlays belong to the thread they were opened in, so leaving its route drops them. */ +function startThreadOverlayRetention(router: AppRouter): void { + router.subscribe("onResolved", () => { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draftSession = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + const threadRef = + target?.kind === "server" + ? target.threadRef + : draftSession + ? scopeThreadRef(draftSession.environmentId, draftSession.threadId) + : null; + retainThreadOverlaysForThread(threadRef ? scopedThreadKey(threadRef) : null); + }); +} + +/** + * Starts every app-lifetime service once, before the first render, and returns the router. + * They run for the life of the page, so their disposers are dropped here. + */ +export function startAppServices(): AppRouter { + // Ahead of the history: the provider auth code must leave the address before anything reads it. + startProviderAuthDelivery(); + + // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. + const router = getRouter(isElectron ? createHashHistory() : createBrowserHistory()); + + if (isElectron) { + syncDocumentElectronPlatformClasses(navigator.platform); + syncDocumentWindowControlsOverlayClass(); + } + + startClientAppearance(); + startDocumentTitle(); + startDefaultThemeAdoption(); + startModelPreferencesMigration(); + startAgentOpenedPreviews(); + startThreadOverlayRetention(router); + startMainAppLocationTracking(router); + startBackgroundThreadAnnouncements(appAtomRegistry, router); + startThreadVisitedMigration(appAtomRegistry); + startSshPasswordPromptQueue(); + + startHostFreezeToasts(); + startLegacyThreadMigrationToast(); + startPermissionUpdateNotices(router); + startThreadNotifications(appAtomRegistry, router); + if (isElectron) startDesktopUpdateLaunchNotice(); + + return router; +} diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index c816836ae8aed..48f8e5955e7e1 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -11,10 +11,9 @@ import { } from "@t3tools/contracts"; export const DEFAULT_SANS_FONT_STACK = - '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; + '-apple-system, BlinkMacSystemFont, "Inter Variable", "Segoe UI", system-ui, sans-serif'; -// Concrete names first: some engines alias `ui-monospace` to the -// proportional system UI font, which would break every code surface. +// Some engines map `ui-monospace` to a proportional font, so prefer concrete names. export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; @@ -69,11 +68,7 @@ export interface AppearanceFontPreferences { readonly smoothing: boolean; } -/** - * Sizes are always written: the interface size drives the root font size (and - * with it every rem-based dimension), while the prompt and code sizes stay in - * absolute pixels so they do not scale twice. - */ +/** Always writes sizes: interface size sets the root; prompt and code use pixels to avoid double scaling. */ export function applyAppearanceFontVariables( root: HTMLElement, preferences: AppearanceFontPreferences, @@ -139,11 +134,7 @@ function probeWidth(fontList: string): number | null { return fontProbeContext.measureText(FONT_PROBE_TEXT).width; } -/** - * Canvas metric probing instead of document.fonts.check(): check() reports true - * for families that are not installed at all (nothing needs loading), so it - * cannot filter the dropdown. - */ +/** Probes canvas metrics because document.fonts.check() accepts uninstalled families. */ export function isFontFamilyAvailable(family: string): boolean { const families = cssFontFamilies(family); if (families === null) return false; @@ -223,11 +214,7 @@ const MONO_GENERIC_CANDIDATES = [ const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; -/** - * Advance width of the probe text laid out by the DOM - not canvas, whose - * generic-family mapping diverges from real rendering (this engine draws - * `ui-monospace` as the proportional UI font on canvas but not in CSS). - */ +// Canvas generic-family mappings can differ from CSS, so measure the DOM. function measureDomProbeWidth(fontFamily: string): number | null { try { const body = document.body; @@ -263,9 +250,7 @@ function resolveGenericFamilyLabel(generic: string): string | null { return candidate; } } - // No nameable face matched; on Apple platforms that means one of the San - // Francisco faces, which CSS cannot name. Comparing against -apple-system - // tells the UI face apart from SF Mono. + // Unnameable Apple faces need -apple-system metrics to distinguish the UI face from SF Mono. if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { const systemWidth = measureDomProbeWidth("-apple-system"); if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; @@ -299,10 +284,7 @@ export interface InstalledFontFamiliesResult { let installedFamiliesCache: InstalledFontFamiliesResult | null = null; -/** - * Call from a user gesture: the first call raises the browser's local-fonts - * permission prompt. - */ +/** Call from a user gesture; the first call prompts for local-fonts permission. */ export async function queryInstalledFontFamilies(): Promise { if (installedFamiliesCache !== null) return installedFamiliesCache; const query = ( @@ -317,12 +299,10 @@ export async function queryInstalledFontFamilies(): Promise font.family))] - // Dot-prefixed families are macOS-internal UI faces; selecting one is - // never intended and most refuse to render for web content anyway. + // Dot-prefixed macOS UI faces often refuse to render in web content. .filter((family) => !family.startsWith(".")) .sort((left, right) => left.localeCompare(right)); - // A denied permission check resolves with an empty list instead of - // throwing; no machine has zero fonts, so treat empty as denied. + // Denied access can return an empty list instead of throwing. if (families.length === 0) return { families: [], status: "denied" }; installedFamiliesCache = { families, status: "granted" }; return installedFamiliesCache; diff --git a/apps/web/src/assets/assetUrls.test.ts b/apps/web/src/assets/assetUrls.test.ts new file mode 100644 index 0000000000000..bb442959f1813 --- /dev/null +++ b/apps/web/src/assets/assetUrls.test.ts @@ -0,0 +1,107 @@ +import { EnvironmentId, ThreadId, type AssetResource } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { makeRevisionedAssetUrlFamily } from "./assetUrls"; + +const environmentId = EnvironmentId.make("environment-1"); +const resource: AssetResource = { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: "docs/diagram.png", +}; + +type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>; + +const minted = (n: number): MintResult => + AsyncResult.success({ relativeUrl: `/a/${n}`, expiresAt: 0 }); + +// A stand-in for the asset URL query: a refresh starts a mint (counted, shown as +// waiting) and `settle` completes the latest one. +function setup(initial: MintResult) { + let mints = 0; + // Node removal runs on the registry's scheduler; `unmount` flushes it so the + // next mount of the same revision builds a fresh node. + const scheduled: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + scheduled.push(task); + return () => {}; + }, + }); + const server = Atom.make(initial).pipe(Atom.keepAlive); + const query = Atom.readable( + (get) => get(server), + () => { + mints += 1; + registry.set(server, AsyncResult.waiting(registry.get(server))); + }, + ).pipe(Atom.keepAlive); + const revisioned = makeRevisionedAssetUrlFamily(() => query); + const mount = (revision: string | null) => { + const release = registry.mount(revisioned({ environmentId, input: { resource }, revision })); + return () => { + release(); + for (const task of scheduled.splice(0)) task(); + }; + }; + const settle = () => registry.set(server, minted(mints + 1)); + return { mount, settle, mints: () => mints }; +} + +describe("makeRevisionedAssetUrlFamily", () => { + it("re-mints a settled URL on first read and on each new revision, not on remount", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-1")(); + expect(mints()).toBe(1); + settle(); + + mount("mutation-1")(); + expect(mints()).toBe(1); + + mount("mutation-2")(); + expect(mints()).toBe(2); + }); + + it("re-mints when a revert returns to an earlier revision", () => { + const { mount, settle, mints } = setup(minted(0)); + + mount("mutation-a")(); + settle(); + mount("mutation-b")(); + settle(); + expect(mints()).toBe(2); + + const reverted = mount("mutation-a"); + expect(mints()).toBe(3); + reverted(); + }); + + it("shares a mint in flight on the first read", () => { + const { mount, mints } = setup(AsyncResult.initial(true)); + + mount("mutation-1")(); + expect(mints()).toBe(0); + }); + + it("re-mints after a mint that was in flight when the revision changed", () => { + const { mount, settle, mints } = setup(minted(0)); + + const before = mount("mutation-1"); + settle(); + const edited = mount("mutation-2"); + expect(mints()).toBe(2); + before(); + + const stillMinting = mount("mutation-3"); + edited(); + expect(mints()).toBe(2); + + settle(); + expect(mints()).toBe(3); + settle(); + expect(mints()).toBe(3); + stillMinting(); + }); +}); diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 38a282279f8f0..6e0dbfb5c6aee 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -7,8 +7,9 @@ import { resolveAssetUrl, } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; +import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { assetEnvironment } from "~/state/assets"; import { useFilesystemReadAccess } from "~/state/filesystem"; @@ -19,16 +20,91 @@ export { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; export type AssetUrlState = RuntimeAssetUrlState & { readonly waiting?: boolean }; +type AssetUrlTarget = { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; +}; + +const decodeRevisionedAssetUrlKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, AssetResource, Schema.NullOr(Schema.String)]), +); + +const isSettled = (result: AsyncResult.AsyncResult) => + !AsyncResult.isInitial(result) && !result.waiting; + +/** + * The URL is minted per resource and shared across revisions, so reading a + * different revision (even an earlier one after a revert) re-mints it. An + * in-flight mint is shared only on first read; after an edit the server must + * re-read the file, so the re-mint waits for that mint to settle. + */ +export function makeRevisionedAssetUrlFamily( + createUrl: ( + target: AssetUrlTarget, + ) => Atom.Atom>, +) { + // Outside the nodes: a node is dropped once unmounted, and the next read of + // its revision must still see the revisions minted since. Never pruned on + // purpose: pruning would forget the revision and re-mint on the next mount. + // It holds one short entry per file shown, and goes with the registry. + const mintedUnder = new WeakMap>(); + const family = Atom.family((key: string) => { + const [environmentId, resource, revision] = decodeRevisionedAssetUrlKey(JSON.parse(key)); + const resourceKey = JSON.stringify([environmentId, resource]); + const query = createUrl({ environmentId, input: { resource } }); + return Atom.transform( + query, + (get) => { + let minted = mintedUnder.get(get.registry); + if (minted === undefined) { + minted = new Map(); + mintedUnder.set(get.registry, minted); + } + const firstRead = !minted.has(resourceKey); + let pending = minted.get(resourceKey) !== revision; + const current = get.once(query); + if (pending && (firstRead || isSettled(current))) { + pending = false; + minted.set(resourceKey, revision); + if (isSettled(current)) get.refresh(query); + } + get.subscribe(query, (value) => { + get.setSelf(value); + if (!pending || !isSettled(value)) return; + pending = false; + minted.set(resourceKey, revision); + get.refresh(query); + }); + return get.once(query); + }, + { initialValueTarget: query }, + // No idle TTL: each mount rebuilds the node, so a revision read again after + // another one is compared against the record instead of a cached node. + ).pipe(Atom.setIdleTTL(0)); + }); + return (target: AssetUrlTarget & { readonly revision: string | null }) => + family(JSON.stringify([target.environmentId, target.input.resource, target.revision])); +} + +export const revisionedAssetUrl = makeRevisionedAssetUrlFamily(assetEnvironment.createUrl); + +/** + * Pass `revision` for a workspace file whose bytes can change under the same + * path; each new revision gets a freshly minted URL. + */ export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, + revision?: string | null, ): AssetUrlState { const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId)); const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( blocked !== null || environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM - : assetEnvironment.createUrl({ environmentId, input: { resource } }), + : revision === undefined + ? assetEnvironment.createUrl({ environmentId, input: { resource } }) + : revisionedAssetUrl({ environmentId, input: { resource }, revision }), ); if (blocked !== null) return blocked; return { diff --git a/apps/web/src/assets/longLivedAssetUrls.test.ts b/apps/web/src/assets/longLivedAssetUrls.test.ts new file mode 100644 index 0000000000000..be444bd9561c5 --- /dev/null +++ b/apps/web/src/assets/longLivedAssetUrls.test.ts @@ -0,0 +1,100 @@ +import { EnvironmentId, type AssetResource } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { makeLongLivedAssetUrlFamily } from "./longLivedAssetUrls"; + +const environmentId = EnvironmentId.make("environment-1"); +const resource: AssetResource = { + _tag: "attachment", + attachmentId: "attachment-1", + fileName: "page.html", + mimeType: "text/html", + disposition: "inline", +}; + +type MintResult = AsyncResult.AsyncResult<{ relativeUrl: string; expiresAt: number }>; + +const url = (relativeUrl: string, expiresAt: number): MintResult => + AsyncResult.success({ relativeUrl, expiresAt }); + +function setup(initial: MintResult) { + let mints = 0; + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => {}; + }, + }); + const server = Atom.make(initial).pipe(Atom.keepAlive); + const query = Atom.readable( + (get) => get(server), + () => { + mints += 1; + registry.set(server, AsyncResult.waiting(registry.get(server))); + }, + ).pipe(Atom.keepAlive); + const family = makeLongLivedAssetUrlFamily(() => query, { minLifeMs: 100, now: () => 1_000 }); + const atom = family({ environmentId, input: { resource } }); + let unmount = registry.mount(atom); + // Readers adopt any Success, waiting or not, as `assetUrlStateFromResult` does. + const relativeUrl = () => { + const value = registry.get(atom); + return value._tag === "Success" ? value.value.relativeUrl : null; + }; + return { + relativeUrl, + mints: () => mints, + serve: (result: MintResult) => registry.set(server, result), + remount: () => { + unmount(); + // Runs the idle-TTL disposal, so the next mount builds a fresh node. + for (const task of tasks.splice(0)) task(); + unmount = registry.mount(atom); + }, + }; +} + +describe("makeLongLivedAssetUrlFamily", () => { + it("reuses a cached URL with enough life left", () => { + const { relativeUrl, mints } = setup(url("/cached", 2_000)); + expect(relativeUrl()).toBe("/cached"); + expect(mints()).toBe(0); + }); + + it("mints a URL near expiry and hides it until the new one arrives", () => { + const { relativeUrl, mints, serve } = setup(url("/stale", 1_050)); + expect(mints()).toBe(1); + expect(relativeUrl()).toBeNull(); + + serve(url("/fresh", 5_000)); + expect(relativeUrl()).toBe("/fresh"); + }); + + it("does not hand a new reader the expiring URL while an earlier reader's mint is in flight", () => { + const { relativeUrl, mints, serve, remount } = setup(url("/stale", 1_050)); + expect(mints()).toBe(1); + remount(); + expect(relativeUrl()).toBeNull(); + + serve(url("/fresh", 5_000)); + expect(relativeUrl()).toBe("/fresh"); + expect(mints()).toBe(1); + }); + + it("mints at most once when the clock disagrees with the server", () => { + const { relativeUrl, mints, serve } = setup(url("/stale", 1_050)); + serve(url("/still-short", 1_050)); + expect(relativeUrl()).toBe("/still-short"); + expect(mints()).toBe(1); + }); + + it("waits for a URL still loading", () => { + const { relativeUrl, mints, serve } = setup(AsyncResult.initial(true)); + expect(relativeUrl()).toBeNull(); + serve(url("/loaded", 5_000)); + expect(relativeUrl()).toBe("/loaded"); + expect(mints()).toBe(0); + }); +}); diff --git a/apps/web/src/assets/longLivedAssetUrls.ts b/apps/web/src/assets/longLivedAssetUrls.ts new file mode 100644 index 0000000000000..2643e44751be2 --- /dev/null +++ b/apps/web/src/assets/longLivedAssetUrls.ts @@ -0,0 +1,87 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type AssetUrlState, + assetUrlStateFromResult, + blockedAssetUrlState, + EMPTY_ASSET_URL_ATOM, +} from "@t3tools/client-runtime/state/assets"; +import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import { assetEnvironment } from "~/state/assets"; +import { useFilesystemReadAccess } from "~/state/filesystem"; +import { usePreparedConnection } from "~/state/session"; + +type AssetUrlTarget = { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; +}; + +const decodeAssetUrlKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, AssetResource])); + +/** + * Asset URLs for documents that cannot report a failed load, such as frames. A + * cached URL with `minLifeMs` left is reused; one closer to expiry is re-minted. + * A node mints at most once, so a skewed client clock cannot mint on every update. + */ +export function makeLongLivedAssetUrlFamily( + createUrl: ( + target: AssetUrlTarget, + ) => Atom.Atom>, + options: { readonly minLifeMs: number; readonly now?: () => number }, +) { + const now = options.now ?? Date.now; + const family = Atom.family((key: string) => { + const [environmentId, resource] = decodeAssetUrlKey(JSON.parse(key)); + const query = createUrl({ environmentId, input: { resource } }); + return Atom.transform( + query, + (get) => { + let minting = false; + const settle = ( + value: AsyncResult.AsyncResult, + ): AsyncResult.AsyncResult => { + if (value._tag !== "Success") return value; + const lasts = value.value.expiresAt - now() > options.minLifeMs; + // An expiring URL must not be handed out while its successor mints, + // whichever reader started the mint. + if (value.waiting) return lasts ? value : AsyncResult.initial(true); + if (minting || lasts) return value; + minting = true; + get.refresh(query); + return AsyncResult.initial(true); + }; + get.subscribe(query, (value) => get.setSelf(settle(value))); + return settle(get.once(query)); + }, + { initialValueTarget: query }, + // Each mount gets its own node, so a later mount may mint again. + ).pipe(Atom.setIdleTTL(0)); + }); + return (target: AssetUrlTarget) => + family(JSON.stringify([target.environmentId, target.input.resource])); +} + +const longLivedAssetUrl = makeLongLivedAssetUrlFamily(assetEnvironment.createUrl, { + minLifeMs: 5 * 60_000, +}); + +/** Pass a null environment once the URL is adopted, so later mints do not re-render. */ +export function useLongLivedAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource, +): AssetUrlState { + const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId)); + const preparedConnection = usePreparedConnection(environmentId); + const result = useAtomValue( + blocked !== null || environmentId === null + ? EMPTY_ASSET_URL_ATOM + : longLivedAssetUrl({ environmentId, input: { resource } }), + ); + if (blocked !== null) return blocked; + return assetUrlStateFromResult( + result, + preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null, + ); +} diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 86823d66b5672..65c6f8edd3424 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -278,8 +278,7 @@ describe("resolveInitialServerAuthGateState", () => { const requiresAuth = { status: "requires-auth", auth: LOOPBACK_AUTH }; await expect(resolveInitialServerAuthGateState()).resolves.toEqual(requiresAuth); - // The pairing form strips the token before submitting. TanStack history - // reloads the auth gate synchronously when replaceState runs. + // Token removal synchronously reloads the TanStack auth gate via replaceState. const token = takePairingTokenFromUrl(); expect(gateLoads).toHaveLength(1); await expect(gateLoads[0]).resolves.toEqual(requiresAuth); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts index 9a3d4a03b3caf..b6f422c5d68f4 100644 --- a/apps/web/src/bootstrap.ts +++ b/apps/web/src/bootstrap.ts @@ -1,5 +1,4 @@ import { showBootError } from "./lib/bootError"; -// Bundled dev can move UI code into shared chunks. Load it only after this -// entry runs the React refresh preamble, and catch failures before React mounts. +// Shared dev chunks need the React refresh preamble first. Catch failures before React mounts. void import("./main").then(({ startup }) => startup).catch(showBootError); diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index fbd843c8d43fd..dd5b707cfebdb 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -306,7 +306,6 @@ export function BrowserDeviceToolbar({ > - {/* Sticky backing so scrolled controls do not show through the close action. */}